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
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { triggerShutdown } from '../shared/shutdown.js';
|
|
4
|
+
|
|
5
|
+
// Graceful shutdown on SIGINT (Ctrl+C) and SIGTERM
|
|
6
|
+
process.on('SIGINT', () => triggerShutdown('SIGINT'));
|
|
7
|
+
process.on('SIGTERM', () => triggerShutdown('SIGTERM'));
|
|
8
|
+
|
|
9
|
+
const program = new Command();
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name('octo')
|
|
13
|
+
.description('Monorepo build orchestration, versioning, and infrastructure CLI')
|
|
14
|
+
.version('0.1.0');
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.command('init')
|
|
18
|
+
.description('Escaneia o monorepo e gera octo.yaml')
|
|
19
|
+
.option('--standalone', 'Gera manifesto apenas para o projeto corrente')
|
|
20
|
+
.action(async (opts) => {
|
|
21
|
+
const { initCommand } = await import('./init.command.js');
|
|
22
|
+
await initCommand(opts);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
program
|
|
26
|
+
.command('graph')
|
|
27
|
+
.description('Exibe o grafo de dependências')
|
|
28
|
+
.action(async () => {
|
|
29
|
+
const { graphCommand } = await import('./graph.command.js');
|
|
30
|
+
await graphCommand();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
program
|
|
34
|
+
.command('build [service]')
|
|
35
|
+
.description('Builda serviços do monorepo')
|
|
36
|
+
.option('--affected', 'Builda apenas serviços afetados')
|
|
37
|
+
.action(async (service, opts) => {
|
|
38
|
+
const { buildCommand } = await import('./build.command.js');
|
|
39
|
+
await buildCommand(service, opts);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
program
|
|
43
|
+
.command('bump <package>')
|
|
44
|
+
.description('Bump package version with semver')
|
|
45
|
+
.argument('[type]', 'Bump type: patch | minor | major', 'patch')
|
|
46
|
+
.option('--install', 'Run pnpm install in updated dependents')
|
|
47
|
+
.option('--push', 'Push commit and tags to remote after bump')
|
|
48
|
+
.option('--tag', 'Create a git tag for the new version')
|
|
49
|
+
.option('--auto', 'Non-interactive mode: skip confirmations, tag and push automatically')
|
|
50
|
+
.action(async (pkg, type, opts) => {
|
|
51
|
+
const { bumpCommand } = await import('./bump.command.js');
|
|
52
|
+
await bumpCommand(pkg, type, opts);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
program
|
|
56
|
+
.command('up [service]')
|
|
57
|
+
.description('Sobe infraestrutura local')
|
|
58
|
+
.action(async (service) => {
|
|
59
|
+
const { upCommand } = await import('./up.command.js');
|
|
60
|
+
await upCommand(service);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
program
|
|
64
|
+
.command('down')
|
|
65
|
+
.description('Para infraestrutura local')
|
|
66
|
+
.option('--volumes', 'Remove volumes também')
|
|
67
|
+
.action(async (opts) => {
|
|
68
|
+
const { downCommand } = await import('./down.command.js');
|
|
69
|
+
await downCommand(opts);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
program
|
|
73
|
+
.command('status')
|
|
74
|
+
.description('Exibe status dos containers')
|
|
75
|
+
.action(async () => {
|
|
76
|
+
const { statusCommand } = await import('./status.command.js');
|
|
77
|
+
await statusCommand();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
program.parse();
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readdir, readFile, access, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { logger } from '../shared/logger.js';
|
|
4
|
+
import { OctoError } from '../shared/errors.js';
|
|
5
|
+
import { printManifest } from '../manifest/manifest-printer.js';
|
|
6
|
+
import type { OctoManifest } from '../manifest/manifest-schema.js';
|
|
7
|
+
|
|
8
|
+
const EXCLUDED_DIRS = new Set(['node_modules', 'dist']);
|
|
9
|
+
const MAX_DEPTH = 5;
|
|
10
|
+
|
|
11
|
+
interface DiscoveredProject {
|
|
12
|
+
name: string;
|
|
13
|
+
relativePath: string;
|
|
14
|
+
hasDockerfile: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isExcluded(name: string): boolean {
|
|
18
|
+
return EXCLUDED_DIRS.has(name) || name.startsWith('.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function exists(path: string): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
await access(path);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function scanDirectory(
|
|
31
|
+
dir: string,
|
|
32
|
+
rootDir: string,
|
|
33
|
+
depth: number,
|
|
34
|
+
results: DiscoveredProject[],
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
if (depth > MAX_DEPTH) return;
|
|
37
|
+
|
|
38
|
+
const pkgPath = join(dir, 'package.json');
|
|
39
|
+
if (await exists(pkgPath)) {
|
|
40
|
+
try {
|
|
41
|
+
const content = await readFile(pkgPath, 'utf-8');
|
|
42
|
+
const pkg = JSON.parse(content);
|
|
43
|
+
if (pkg.name) {
|
|
44
|
+
const hasDockerfile = await exists(join(dir, 'Dockerfile'));
|
|
45
|
+
results.push({
|
|
46
|
+
name: pkg.name,
|
|
47
|
+
relativePath: './' + relative(rootDir, dir),
|
|
48
|
+
hasDockerfile,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Invalid JSON — skip
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
59
|
+
} catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (!entry.isDirectory() || isExcluded(entry.name)) continue;
|
|
65
|
+
await scanDirectory(join(dir, entry.name), rootDir, depth + 1, results);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function initCommand(opts: { standalone?: boolean }): Promise<void> {
|
|
70
|
+
const rootDir = process.cwd();
|
|
71
|
+
const results: DiscoveredProject[] = [];
|
|
72
|
+
|
|
73
|
+
if (opts.standalone) {
|
|
74
|
+
// Only scan current directory, no recursion into subdirectories
|
|
75
|
+
const pkgPath = join(rootDir, 'package.json');
|
|
76
|
+
if (await exists(pkgPath)) {
|
|
77
|
+
try {
|
|
78
|
+
const content = await readFile(pkgPath, 'utf-8');
|
|
79
|
+
const pkg = JSON.parse(content);
|
|
80
|
+
if (pkg.name) {
|
|
81
|
+
const hasDockerfile = await exists(join(rootDir, 'Dockerfile'));
|
|
82
|
+
results.push({ name: pkg.name, relativePath: '.', hasDockerfile });
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Invalid JSON
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
await scanDirectory(rootDir, rootDir, 0, results);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (results.length === 0) {
|
|
93
|
+
throw new OctoError('Nenhum pacote com package.json válido encontrado.', 1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const services = results.filter((p) => p.hasDockerfile).map((p) => p.name);
|
|
97
|
+
const packages = results.filter((p) => !p.hasDockerfile).map((p) => p.name);
|
|
98
|
+
|
|
99
|
+
const manifest: OctoManifest = { services, packages };
|
|
100
|
+
const yaml = printManifest(manifest);
|
|
101
|
+
|
|
102
|
+
const outputPath = join(rootDir, 'octo.yaml');
|
|
103
|
+
await writeFile(outputPath, yaml, 'utf-8');
|
|
104
|
+
|
|
105
|
+
logger.info(`octo.yaml gerado com ${services.length} serviço(s) e ${packages.length} pacote(s).`);
|
|
106
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { parseManifest } from '../manifest/manifest-parser.js';
|
|
4
|
+
import { buildGraphFromManifest } from '../graph/build-graph.js';
|
|
5
|
+
import { createInfraManager } from '../infra/infra-manager.js';
|
|
6
|
+
import { logger } from '../shared/logger.js';
|
|
7
|
+
import { OctoError } from '../shared/errors.js';
|
|
8
|
+
|
|
9
|
+
export async function statusCommand(): Promise<void> {
|
|
10
|
+
const rootDir = process.cwd();
|
|
11
|
+
const manifestPath = resolve(rootDir, 'octo.yaml');
|
|
12
|
+
|
|
13
|
+
let content: string;
|
|
14
|
+
try {
|
|
15
|
+
content = readFileSync(manifestPath, 'utf-8');
|
|
16
|
+
} catch {
|
|
17
|
+
throw new OctoError('octo.yaml não encontrado. Execute `octo init` primeiro.');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const parsed = parseManifest(content, manifestPath);
|
|
21
|
+
if (!parsed.ok) throw parsed.error;
|
|
22
|
+
|
|
23
|
+
const graph = buildGraphFromManifest(parsed.value, rootDir);
|
|
24
|
+
const servicePaths = graph.getNodeNames()
|
|
25
|
+
.map((name) => graph.getNode(name)?.path)
|
|
26
|
+
.filter((p): p is string => !!p);
|
|
27
|
+
|
|
28
|
+
const manager = createInfraManager(servicePaths);
|
|
29
|
+
const containers = await manager.status();
|
|
30
|
+
|
|
31
|
+
if (containers.length === 0) {
|
|
32
|
+
logger.info('Nenhum container em execução.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Print table header
|
|
37
|
+
const header = `${'NOME'.padEnd(30)} ${'IMAGEM'.padEnd(35)} ${'ESTADO'.padEnd(12)} PORTA`;
|
|
38
|
+
console.log(header);
|
|
39
|
+
console.log('-'.repeat(header.length));
|
|
40
|
+
|
|
41
|
+
for (const c of containers) {
|
|
42
|
+
console.log(
|
|
43
|
+
`${c.name.padEnd(30)} ${c.image.padEnd(35)} ${c.state.padEnd(12)} ${c.port}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { parseManifest } from '../manifest/manifest-parser.js';
|
|
4
|
+
import { buildGraphFromManifest } from '../graph/build-graph.js';
|
|
5
|
+
import { createInfraManager } from '../infra/infra-manager.js';
|
|
6
|
+
import { logger } from '../shared/logger.js';
|
|
7
|
+
import { OctoError } from '../shared/errors.js';
|
|
8
|
+
|
|
9
|
+
export async function upCommand(service?: string): Promise<void> {
|
|
10
|
+
const rootDir = process.cwd();
|
|
11
|
+
const manifestPath = resolve(rootDir, 'octo.yaml');
|
|
12
|
+
|
|
13
|
+
let content: string;
|
|
14
|
+
try {
|
|
15
|
+
content = readFileSync(manifestPath, 'utf-8');
|
|
16
|
+
} catch {
|
|
17
|
+
throw new OctoError('octo.yaml não encontrado. Execute `octo init` primeiro.');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const parsed = parseManifest(content, manifestPath);
|
|
21
|
+
if (!parsed.ok) throw parsed.error;
|
|
22
|
+
|
|
23
|
+
const graph = buildGraphFromManifest(parsed.value, rootDir);
|
|
24
|
+
|
|
25
|
+
// Resolve service paths from graph
|
|
26
|
+
let targetServices: string[] | undefined;
|
|
27
|
+
if (service) {
|
|
28
|
+
const node = graph.getNode(service);
|
|
29
|
+
if (!node) {
|
|
30
|
+
const available = graph.getNodeNames().join(', ');
|
|
31
|
+
throw new OctoError(`Serviço "${service}" não encontrado. Disponíveis: ${available}`);
|
|
32
|
+
}
|
|
33
|
+
// Include service + its dependencies
|
|
34
|
+
const deps = graph.getDependencies(service);
|
|
35
|
+
targetServices = [service, ...deps];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Collect all service paths from graph nodes
|
|
39
|
+
const allNames = targetServices ?? graph.getNodeNames();
|
|
40
|
+
const servicePaths = allNames
|
|
41
|
+
.map((name) => graph.getNode(name)?.path)
|
|
42
|
+
.filter((p): p is string => !!p);
|
|
43
|
+
|
|
44
|
+
const manager = createInfraManager(servicePaths);
|
|
45
|
+
const result = await manager.up(targetServices);
|
|
46
|
+
|
|
47
|
+
if (!result.success) {
|
|
48
|
+
throw new OctoError(result.message);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
logger.info(result.message);
|
|
52
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { DependencyGraph } from './dependency-graph.js';
|
|
4
|
+
import { buildGraphFromManifest } from './build-graph.js';
|
|
5
|
+
import { parseManifest } from '../manifest/manifest-parser.js';
|
|
6
|
+
import type { DiscoveredManifest } from '../manifest/manifest-discovery.js';
|
|
7
|
+
import { logger } from '../shared/logger.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Builds a unified DependencyGraph from multiple discovered manifests (aggregated mode).
|
|
11
|
+
* Resolves cross-project dependencies: if service S in manifest B depends on package P
|
|
12
|
+
* declared in manifest A, adds an edge from S to P in the unified graph.
|
|
13
|
+
*/
|
|
14
|
+
export function buildAggregatedGraph(manifests: DiscoveredManifest[]): DependencyGraph {
|
|
15
|
+
const unified = new DependencyGraph();
|
|
16
|
+
const localGraphs: DependencyGraph[] = [];
|
|
17
|
+
|
|
18
|
+
for (const manifest of manifests) {
|
|
19
|
+
let content: string;
|
|
20
|
+
try {
|
|
21
|
+
content = readFileSync(manifest.path, 'utf-8');
|
|
22
|
+
} catch {
|
|
23
|
+
logger.warn(`Could not read manifest: ${manifest.path}`);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result = parseManifest(content, manifest.path);
|
|
28
|
+
if (!result.ok) {
|
|
29
|
+
logger.warn(`Failed to parse manifest ${manifest.path}: ${result.error.message}`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const rootDir = dirname(manifest.path);
|
|
34
|
+
const graph = buildGraphFromManifest(result.value, rootDir);
|
|
35
|
+
localGraphs.push(graph);
|
|
36
|
+
|
|
37
|
+
// Merge nodes into unified graph
|
|
38
|
+
for (const name of graph.getNodeNames()) {
|
|
39
|
+
const node = graph.getNode(name)!;
|
|
40
|
+
unified.addNode(node);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Merge local edges
|
|
44
|
+
for (const name of graph.getNodeNames()) {
|
|
45
|
+
for (const dep of graph.getDependencies(name)) {
|
|
46
|
+
unified.addEdge(name, dep);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Resolve cross-project edges: for each node, check if its package.json
|
|
52
|
+
// dependencies reference a node from another manifest's graph
|
|
53
|
+
const allNodeNames = new Set(unified.getNodeNames());
|
|
54
|
+
|
|
55
|
+
for (const name of unified.getNodeNames()) {
|
|
56
|
+
const node = unified.getNode(name)!;
|
|
57
|
+
// Read package.json to find all dependencies
|
|
58
|
+
const pkgPath = `${node.path}/package.json`;
|
|
59
|
+
let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
60
|
+
try {
|
|
61
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
62
|
+
} catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
67
|
+
for (const depName of Object.keys(allDeps)) {
|
|
68
|
+
// Only add edge if the dependency is an internal node and not already connected
|
|
69
|
+
if (allNodeNames.has(depName) && !unified.getDependencies(name).includes(depName)) {
|
|
70
|
+
unified.addEdge(name, depName);
|
|
71
|
+
logger.info(`Cross-project edge: ${name} → ${depName}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return unified;
|
|
77
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { DependencyGraph, type GraphNode } from './dependency-graph.js';
|
|
4
|
+
import type { OctoManifest, ServiceEntry, PackageEntry } from '../manifest/manifest-schema.js';
|
|
5
|
+
|
|
6
|
+
interface PackageJson {
|
|
7
|
+
name?: string;
|
|
8
|
+
dependencies?: Record<string, string>;
|
|
9
|
+
devDependencies?: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Extract the name and optional path override from a manifest entry */
|
|
13
|
+
function resolveEntry(entry: ServiceEntry | PackageEntry): { name: string; path?: string } {
|
|
14
|
+
if (typeof entry === 'string') return { name: entry };
|
|
15
|
+
// Object format: { "auth": { path?: "./custom" } }
|
|
16
|
+
const key = Object.keys(entry)[0];
|
|
17
|
+
const config = (entry as Record<string, { path?: string }>)[key];
|
|
18
|
+
return { name: key, path: config?.path };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Recursively search for a directory containing a package.json whose `name` matches `targetName`.
|
|
23
|
+
* Searches up to depth 3 from rootDir.
|
|
24
|
+
*/
|
|
25
|
+
function findPackageDir(rootDir: string, targetName: string, maxDepth = 3): string | undefined {
|
|
26
|
+
function search(dir: string, depth: number): string | undefined {
|
|
27
|
+
if (depth > maxDepth) return undefined;
|
|
28
|
+
|
|
29
|
+
const pkgPath = join(dir, 'package.json');
|
|
30
|
+
if (existsSync(pkgPath)) {
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as PackageJson;
|
|
33
|
+
if (pkg.name === targetName) return dir;
|
|
34
|
+
} catch { /* skip invalid json */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let entries: string[];
|
|
38
|
+
try {
|
|
39
|
+
entries = readdirSync(dir);
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (entry === 'node_modules' || entry === 'dist' || entry.startsWith('.')) continue;
|
|
46
|
+
const full = join(dir, entry);
|
|
47
|
+
try {
|
|
48
|
+
if (statSync(full).isDirectory()) {
|
|
49
|
+
const found = search(full, depth + 1);
|
|
50
|
+
if (found) return found;
|
|
51
|
+
}
|
|
52
|
+
} catch { /* skip inaccessible */ }
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return search(rootDir, 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read a package.json from a directory, returning undefined if not found */
|
|
61
|
+
function readPackageJson(dir: string): PackageJson | undefined {
|
|
62
|
+
const pkgPath = join(dir, 'package.json');
|
|
63
|
+
if (!existsSync(pkgPath)) return undefined;
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readFileSync(pkgPath, 'utf-8')) as PackageJson;
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a DependencyGraph from an OctoManifest.
|
|
73
|
+
* Reads each service/package's package.json and adds edges only for internal dependencies.
|
|
74
|
+
*/
|
|
75
|
+
export function buildGraphFromManifest(manifest: OctoManifest, rootDir: string): DependencyGraph {
|
|
76
|
+
const graph = new DependencyGraph();
|
|
77
|
+
const resolvedPaths = new Map<string, string>();
|
|
78
|
+
|
|
79
|
+
// Collect all declared names (services + packages)
|
|
80
|
+
const allEntries: Array<{ name: string; path?: string; type: 'service' | 'package' }> = [];
|
|
81
|
+
|
|
82
|
+
for (const entry of manifest.services) {
|
|
83
|
+
const resolved = resolveEntry(entry);
|
|
84
|
+
allEntries.push({ ...resolved, type: 'service' });
|
|
85
|
+
}
|
|
86
|
+
for (const entry of manifest.packages) {
|
|
87
|
+
const resolved = resolveEntry(entry);
|
|
88
|
+
allEntries.push({ ...resolved, type: 'package' });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Resolve paths and add nodes
|
|
92
|
+
for (const entry of allEntries) {
|
|
93
|
+
let dir: string | undefined;
|
|
94
|
+
|
|
95
|
+
if (entry.path) {
|
|
96
|
+
dir = resolve(rootDir, entry.path);
|
|
97
|
+
} else {
|
|
98
|
+
dir = findPackageDir(rootDir, entry.name);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!dir) continue;
|
|
102
|
+
|
|
103
|
+
resolvedPaths.set(entry.name, dir);
|
|
104
|
+
const node: GraphNode = { name: entry.name, type: entry.type, path: dir };
|
|
105
|
+
graph.addNode(node);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Set of all internal package names for quick lookup
|
|
109
|
+
const internalNames = new Set(resolvedPaths.keys());
|
|
110
|
+
|
|
111
|
+
// Add edges based on package.json dependencies
|
|
112
|
+
for (const [name, dir] of resolvedPaths) {
|
|
113
|
+
const pkg = readPackageJson(dir);
|
|
114
|
+
if (!pkg) continue;
|
|
115
|
+
|
|
116
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
117
|
+
for (const depName of Object.keys(allDeps)) {
|
|
118
|
+
if (internalNames.has(depName)) {
|
|
119
|
+
graph.addEdge(name, depName);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return graph;
|
|
125
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { CycleError } from '../shared/errors.js';
|
|
2
|
+
import { topologicalSort } from './topological-sort.js';
|
|
3
|
+
|
|
4
|
+
export interface GraphNode {
|
|
5
|
+
name: string;
|
|
6
|
+
type: 'service' | 'package';
|
|
7
|
+
path: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SortResult =
|
|
11
|
+
| { ok: true; value: string[] }
|
|
12
|
+
| { ok: false; error: CycleError };
|
|
13
|
+
|
|
14
|
+
export class DependencyGraph {
|
|
15
|
+
/** node name → GraphNode metadata */
|
|
16
|
+
private nodes = new Map<string, GraphNode>();
|
|
17
|
+
/** node name → set of dependency names (edges: from depends on to) */
|
|
18
|
+
private edges = new Map<string, Set<string>>();
|
|
19
|
+
/** reverse edges: node name → set of dependents */
|
|
20
|
+
private reverseEdges = new Map<string, Set<string>>();
|
|
21
|
+
|
|
22
|
+
addNode(node: GraphNode): void {
|
|
23
|
+
this.nodes.set(node.name, node);
|
|
24
|
+
if (!this.edges.has(node.name)) this.edges.set(node.name, new Set());
|
|
25
|
+
if (!this.reverseEdges.has(node.name)) this.reverseEdges.set(node.name, new Set());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
addEdge(from: string, to: string): void {
|
|
29
|
+
// from depends on to
|
|
30
|
+
if (!this.edges.has(from)) this.edges.set(from, new Set());
|
|
31
|
+
if (!this.reverseEdges.has(to)) this.reverseEdges.set(to, new Set());
|
|
32
|
+
this.edges.get(from)!.add(to);
|
|
33
|
+
this.reverseEdges.get(to)!.add(from);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Get packages that `packageName` depends on */
|
|
37
|
+
getDependencies(packageName: string): string[] {
|
|
38
|
+
return [...(this.edges.get(packageName) ?? [])];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Get packages that depend on `packageName` */
|
|
42
|
+
getDependents(packageName: string): string[] {
|
|
43
|
+
return [...(this.reverseEdges.get(packageName) ?? [])];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Group nodes into independent parallel levels (BFS layers) */
|
|
47
|
+
getIndependentGroups(): string[][] {
|
|
48
|
+
const sortResult = this.topologicalSort();
|
|
49
|
+
if (!sortResult.ok) return [];
|
|
50
|
+
|
|
51
|
+
const sorted = sortResult.value;
|
|
52
|
+
const levels: string[][] = [];
|
|
53
|
+
const assigned = new Set<string>();
|
|
54
|
+
|
|
55
|
+
while (assigned.size < sorted.length) {
|
|
56
|
+
const level = sorted.filter(
|
|
57
|
+
(n) => !assigned.has(n) && this.getDependencies(n).every((d) => assigned.has(d)),
|
|
58
|
+
);
|
|
59
|
+
if (level.length === 0) break;
|
|
60
|
+
levels.push(level);
|
|
61
|
+
level.forEach((n) => assigned.add(n));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return levels;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
topologicalSort(): SortResult {
|
|
68
|
+
return topologicalSort(this.edges);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getNode(name: string): GraphNode | undefined {
|
|
72
|
+
return this.nodes.get(name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getNodeNames(): string[] {
|
|
76
|
+
return [...this.nodes.keys()];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get size(): number {
|
|
80
|
+
return this.nodes.size;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CycleError } from '../shared/errors.js';
|
|
2
|
+
|
|
3
|
+
export type SortResult =
|
|
4
|
+
| { ok: true; value: string[] }
|
|
5
|
+
| { ok: false; error: CycleError };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Kahn's Algorithm — topological sort with cycle detection.
|
|
9
|
+
* `edges` maps each node to the set of nodes it depends on (outgoing edges point to dependencies).
|
|
10
|
+
*/
|
|
11
|
+
export function topologicalSort(edges: Map<string, Set<string>>): SortResult {
|
|
12
|
+
// In-degree: how many nodes depend on this node? No — in-degree = how many dependencies point INTO this node.
|
|
13
|
+
// Actually for Kahn's: in-degree of a node = number of prerequisites it has.
|
|
14
|
+
// We want to produce an order where dependencies come first.
|
|
15
|
+
// edges: from → {deps it depends on}. So "from depends on to" means to must come before from.
|
|
16
|
+
// In-degree of `from` = edges.get(from).size (number of things it depends on).
|
|
17
|
+
|
|
18
|
+
const inDegree = new Map<string, number>();
|
|
19
|
+
// Initialize all nodes
|
|
20
|
+
for (const [node] of edges) {
|
|
21
|
+
if (!inDegree.has(node)) inDegree.set(node, 0);
|
|
22
|
+
}
|
|
23
|
+
// Calculate in-degree: for each node, its in-degree is the count of its dependencies
|
|
24
|
+
for (const [node, deps] of edges) {
|
|
25
|
+
inDegree.set(node, deps.size);
|
|
26
|
+
// Ensure all dependency targets are in the map
|
|
27
|
+
for (const dep of deps) {
|
|
28
|
+
if (!inDegree.has(dep)) inDegree.set(dep, 0);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Enqueue nodes with in-degree 0 (no dependencies)
|
|
33
|
+
const queue: string[] = [];
|
|
34
|
+
for (const [node, degree] of inDegree) {
|
|
35
|
+
if (degree === 0) queue.push(node);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const result: string[] = [];
|
|
39
|
+
|
|
40
|
+
while (queue.length > 0) {
|
|
41
|
+
const node = queue.shift()!;
|
|
42
|
+
result.push(node);
|
|
43
|
+
|
|
44
|
+
// For each other node that depends on `node`, decrement its in-degree
|
|
45
|
+
for (const [candidate, deps] of edges) {
|
|
46
|
+
if (deps.has(node)) {
|
|
47
|
+
const newDegree = inDegree.get(candidate)! - 1;
|
|
48
|
+
inDegree.set(candidate, newDegree);
|
|
49
|
+
if (newDegree === 0) queue.push(candidate);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (result.length !== inDegree.size) {
|
|
55
|
+
// Cycle detected — trace it
|
|
56
|
+
const cycleNodes = [...inDegree.entries()]
|
|
57
|
+
.filter(([, d]) => d > 0)
|
|
58
|
+
.map(([n]) => n);
|
|
59
|
+
const cycle = traceCycle(edges, cycleNodes);
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: new CycleError(
|
|
63
|
+
`Dependency cycle detected: ${cycle.join(' -> ')}`,
|
|
64
|
+
cycle,
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { ok: true, value: result };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Trace a cycle path from the remaining nodes with in-degree > 0 */
|
|
73
|
+
function traceCycle(edges: Map<string, Set<string>>, cycleNodes: string[]): string[] {
|
|
74
|
+
const inCycle = new Set(cycleNodes);
|
|
75
|
+
if (cycleNodes.length === 0) return [];
|
|
76
|
+
|
|
77
|
+
const start = cycleNodes[0];
|
|
78
|
+
const path: string[] = [start];
|
|
79
|
+
const visited = new Set<string>([start]);
|
|
80
|
+
let current = start;
|
|
81
|
+
|
|
82
|
+
while (true) {
|
|
83
|
+
const deps = edges.get(current);
|
|
84
|
+
if (!deps) break;
|
|
85
|
+
const next = [...deps].find((d) => inCycle.has(d) && !visited.has(d));
|
|
86
|
+
if (!next) {
|
|
87
|
+
// Close the cycle back to start
|
|
88
|
+
const closing = [...deps].find((d) => d === start);
|
|
89
|
+
if (closing) path.push(closing);
|
|
90
|
+
else {
|
|
91
|
+
// Find any node already in path to close
|
|
92
|
+
const back = [...deps].find((d) => path.includes(d));
|
|
93
|
+
if (back) path.push(back);
|
|
94
|
+
else path.push(start);
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
path.push(next);
|
|
99
|
+
visited.add(next);
|
|
100
|
+
current = next;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return path;
|
|
104
|
+
}
|