octo-dev 0.8.1 → 0.10.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/package.json +5 -1
- package/src/cli/add.command.ts +69 -4
- package/src/cli/config.command.ts +1 -1
- package/src/cli/graph.command.ts +28 -12
- package/src/cli/index.ts +1 -1
- package/src/graph/build-graph.ts +37 -43
- package/src/infra/compose-aggregator.ts +2 -2
- package/src/infra/compose-smart-merger.ts +16 -16
- package/src/manifest/adapters/node-package-reader.adapter.ts +32 -0
- package/src/manifest/manifest-discovery.ts +4 -4
- package/src/manifest/manifest-mutator.ts +2 -2
- package/src/manifest/ports/package-reader.port.ts +20 -0
- package/src/shared/config.ts +4 -4
- package/src/shared/sync.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "octo-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Build orchestration, semantic versioning, and local infrastructure management for repository workspaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,16 +49,20 @@
|
|
|
49
49
|
"@ai-sdk/anthropic": "^3.0.81",
|
|
50
50
|
"commander": "^13.1.0",
|
|
51
51
|
"execa": "^9.6.1",
|
|
52
|
+
"fs-extra": "^11.3.5",
|
|
52
53
|
"pino": "^10.3.1",
|
|
53
54
|
"pino-pretty": "^13.1.3",
|
|
54
55
|
"semver": "^7.7.2",
|
|
56
|
+
"treeify": "^1.1.0",
|
|
55
57
|
"tsx": "^4.19.4",
|
|
56
58
|
"yaml": "^2.7.1",
|
|
57
59
|
"zod": "^3.25.67"
|
|
58
60
|
},
|
|
59
61
|
"devDependencies": {
|
|
62
|
+
"@types/fs-extra": "^11.0.4",
|
|
60
63
|
"@types/node": "^22.15.0",
|
|
61
64
|
"@types/semver": "^7.7.0",
|
|
65
|
+
"@types/treeify": "^1.0.3",
|
|
62
66
|
"fast-check": "^4.1.1",
|
|
63
67
|
"typescript": "^5.8.3",
|
|
64
68
|
"vitest": "^4.1.8"
|
package/src/cli/add.command.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { resolve } from 'node:path';
|
|
1
|
+
import { pathExistsSync, readJsonSync } from 'fs-extra';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
3
|
import { logger } from '../shared/logger.js';
|
|
4
4
|
import { OctoError } from '../shared/errors.js';
|
|
5
5
|
import { extractRepoName, cloneRepository } from '../shared/git.js';
|
|
@@ -10,18 +10,75 @@ export interface AddCommandOptions {
|
|
|
10
10
|
name?: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Reads dependencies from a project's package.json.
|
|
15
|
+
*
|
|
16
|
+
* @param projectDir - Absolute path to the project directory.
|
|
17
|
+
* @returns Combined dependency names from dependencies and devDependencies.
|
|
18
|
+
*/
|
|
19
|
+
function getProjectDeps(projectDir: string): string[] {
|
|
20
|
+
const pkgPath = join(projectDir, 'package.json');
|
|
21
|
+
if (!pathExistsSync(pkgPath)) return [];
|
|
22
|
+
try {
|
|
23
|
+
const pkg = readJsonSync(pkgPath);
|
|
24
|
+
return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Detects dependency relationships between the new project and existing workspace projects.
|
|
32
|
+
* Reports which existing projects depend on the new one, and which the new one depends on.
|
|
33
|
+
*
|
|
34
|
+
* @param newProjectName - The package name of the newly added project.
|
|
35
|
+
* @param newProjectDir - The directory of the newly cloned project.
|
|
36
|
+
* @param existingEntries - Names of projects already in the manifest.
|
|
37
|
+
* @param rootDir - Workspace root directory.
|
|
38
|
+
*/
|
|
39
|
+
function reportDependencies(
|
|
40
|
+
newProjectName: string,
|
|
41
|
+
newProjectDir: string,
|
|
42
|
+
existingEntries: string[],
|
|
43
|
+
rootDir: string,
|
|
44
|
+
): void {
|
|
45
|
+
const newDeps = getProjectDeps(newProjectDir);
|
|
46
|
+
const existingSet = new Set(existingEntries);
|
|
47
|
+
|
|
48
|
+
// What does the new project depend on that's already in the workspace?
|
|
49
|
+
const depsInWorkspace = newDeps.filter((d) => existingSet.has(d));
|
|
50
|
+
if (depsInWorkspace.length > 0) {
|
|
51
|
+
logger.info(` → depends on: ${depsInWorkspace.join(', ')}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Which existing projects depend on the new one?
|
|
55
|
+
const dependents: string[] = [];
|
|
56
|
+
for (const name of existingEntries) {
|
|
57
|
+
const dir = resolve(rootDir, extractRepoName(name));
|
|
58
|
+
if (!pathExistsSync(dir)) continue;
|
|
59
|
+
const deps = getProjectDeps(dir);
|
|
60
|
+
if (deps.includes(newProjectName)) {
|
|
61
|
+
dependents.push(name);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (dependents.length > 0) {
|
|
66
|
+
logger.info(` → depended on by: ${dependents.join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
13
70
|
/**
|
|
14
71
|
* octo add <repo-url>
|
|
15
72
|
*
|
|
16
73
|
* Clones a repository and registers it in octo.yaml.
|
|
17
|
-
*
|
|
74
|
+
* After registration, reports dependency relationships with existing workspace projects.
|
|
18
75
|
*/
|
|
19
76
|
export async function addCommand(repoUrl: string, opts: AddCommandOptions): Promise<void> {
|
|
20
77
|
const rootDir = process.cwd();
|
|
21
78
|
const dirName = opts.name || extractRepoName(repoUrl);
|
|
22
79
|
const targetDir = resolve(rootDir, dirName);
|
|
23
80
|
|
|
24
|
-
if (
|
|
81
|
+
if (pathExistsSync(targetDir)) {
|
|
25
82
|
throw new OctoError(`Directory already exists: ${dirName}. Use --name to specify another name.`);
|
|
26
83
|
}
|
|
27
84
|
|
|
@@ -33,6 +90,12 @@ export async function addCommand(repoUrl: string, opts: AddCommandOptions): Prom
|
|
|
33
90
|
const manifestPath = resolve(rootDir, 'octo.yaml');
|
|
34
91
|
const { manifest, originalContent } = loadOrCreateManifest(manifestPath);
|
|
35
92
|
|
|
93
|
+
// Collect existing entries before adding
|
|
94
|
+
const existingEntries = [
|
|
95
|
+
...manifest.services.map((e) => typeof e === 'string' ? e : Object.keys(e)[0]),
|
|
96
|
+
...(manifest.packages ?? []).map((e) => typeof e === 'string' ? e : Object.keys(e)[0]),
|
|
97
|
+
];
|
|
98
|
+
|
|
36
99
|
const added = addEntry(manifest, projectName, dirName, type);
|
|
37
100
|
|
|
38
101
|
if (!added) {
|
|
@@ -42,4 +105,6 @@ export async function addCommand(repoUrl: string, opts: AddCommandOptions): Prom
|
|
|
42
105
|
|
|
43
106
|
saveManifest(manifestPath, manifest, originalContent);
|
|
44
107
|
logger.info(`Project "${projectName}" added as ${type} in octo.yaml.`);
|
|
108
|
+
|
|
109
|
+
reportDependencies(projectName, targetDir, existingEntries, rootDir);
|
|
45
110
|
}
|
|
@@ -5,7 +5,7 @@ import { ask, askSecret } from '../shared/prompt.js';
|
|
|
5
5
|
|
|
6
6
|
const PROVIDERS: Record<string, { baseUrl: string; defaultModel: string }> = {
|
|
7
7
|
openai: { baseUrl: 'https://openai.com/v1', defaultModel: 'gpt-4o-mini' },
|
|
8
|
-
gemini: { baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', defaultModel: 'gemini-2.
|
|
8
|
+
gemini: { baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', defaultModel: 'gemini-2.5-flash' },
|
|
9
9
|
groq: { baseUrl: 'https://api.groq.com/openai/v1', defaultModel: 'llama-3.3-70b-versatile' },
|
|
10
10
|
anthropic: { baseUrl: 'https://api.anthropic.com/v1', defaultModel: 'claude-sonnet-4-20250514' },
|
|
11
11
|
custom: { baseUrl: '', defaultModel: '' },
|
package/src/cli/graph.command.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import treeify from 'treeify';
|
|
3
4
|
import { parseManifest } from '../manifest/manifest-parser.js';
|
|
4
5
|
import { buildGraphFromManifest } from '../graph/build-graph.js';
|
|
5
6
|
import { OctoError } from '../shared/errors.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
|
-
* `octo graph` — displays the dependency graph
|
|
9
|
+
* `octo graph` — displays the dependency graph as a tree in the terminal using treeify.
|
|
9
10
|
*/
|
|
10
11
|
export async function graphCommand(): Promise<void> {
|
|
11
12
|
const cwd = process.cwd();
|
|
@@ -19,22 +20,37 @@ export async function graphCommand(): Promise<void> {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
const result = parseManifest(content, manifestPath);
|
|
22
|
-
if (!result.ok)
|
|
23
|
-
throw result.error;
|
|
24
|
-
}
|
|
23
|
+
if (!result.ok) throw result.error;
|
|
25
24
|
|
|
26
25
|
const graph = buildGraphFromManifest(result.value, cwd);
|
|
27
26
|
const sortResult = graph.topologicalSort();
|
|
28
27
|
|
|
29
|
-
if (!sortResult.ok)
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
if (!sortResult.ok) throw sortResult.error;
|
|
29
|
+
|
|
30
|
+
const allNames = sortResult.value;
|
|
31
|
+
const totalEdges = allNames.reduce((sum, n) => sum + graph.getDependencies(n).length, 0);
|
|
32
32
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
// Build treeify-compatible object
|
|
34
|
+
const tree: Record<string, any> = {};
|
|
35
|
+
|
|
36
|
+
for (const name of allNames) {
|
|
37
|
+
const deps = graph.getDependencies(name);
|
|
38
|
+
const node = graph.getNode(name);
|
|
39
|
+
const icon = node?.type === 'service' ? '●' : '○';
|
|
40
|
+
const label = `${icon} ${name}`;
|
|
41
|
+
|
|
42
|
+
if (deps.length === 0) {
|
|
43
|
+
tree[label] = null;
|
|
44
|
+
} else {
|
|
45
|
+
const children: Record<string, null> = {};
|
|
46
|
+
for (const dep of deps) {
|
|
47
|
+
children[`→ ${dep}`] = null;
|
|
48
|
+
}
|
|
49
|
+
tree[label] = children;
|
|
38
50
|
}
|
|
39
51
|
}
|
|
52
|
+
|
|
53
|
+
console.log(`\n Dependency Graph (${allNames.length} nodes, ${totalEdges} edges)\n`);
|
|
54
|
+
console.log(treeify.asTree(tree, true, true));
|
|
55
|
+
console.log(' ● service ○ package → depends on\n');
|
|
40
56
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ const program = new Command();
|
|
|
11
11
|
program
|
|
12
12
|
.name('octo')
|
|
13
13
|
.description('Build orchestration, semantic versioning, and local infrastructure management for repository workspaces')
|
|
14
|
-
.version('0.
|
|
14
|
+
.version('0.10.0');
|
|
15
15
|
|
|
16
16
|
program
|
|
17
17
|
.command('init')
|
package/src/graph/build-graph.ts
CHANGED
|
@@ -1,39 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { pathExistsSync, readdirSync, statSync } from 'fs-extra';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { DependencyGraph, type GraphNode } from './dependency-graph.js';
|
|
4
4
|
import { isRemoteRepo, extractRepoName } from '../shared/git.js';
|
|
5
|
+
import { NodePackageReader } from '../manifest/adapters/node-package-reader.adapter.js';
|
|
6
|
+
import type { PackageReader } from '../manifest/ports/package-reader.port.js';
|
|
5
7
|
import type { OctoManifest, ServiceEntry, PackageEntry } from '../manifest/manifest-schema.js';
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
/** Extract the name and optional path override from a manifest entry */
|
|
9
|
+
/**
|
|
10
|
+
* Extracts the name and optional path override from a manifest entry.
|
|
11
|
+
*
|
|
12
|
+
* @param entry - A service or package entry (string or object with path config).
|
|
13
|
+
* @returns Resolved name and optional explicit path.
|
|
14
|
+
*/
|
|
14
15
|
function resolveEntry(entry: ServiceEntry | PackageEntry): { name: string; path?: string } {
|
|
15
16
|
if (typeof entry === 'string') return { name: entry };
|
|
16
|
-
// Object format: { "auth": { path?: "./custom" } }
|
|
17
17
|
const key = Object.keys(entry)[0];
|
|
18
18
|
const config = (entry as Record<string, { path?: string }>)[key];
|
|
19
19
|
return { name: key, path: config?.path };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Recursively
|
|
24
|
-
*
|
|
23
|
+
* Recursively searches for a directory whose package.json `name` matches targetName.
|
|
24
|
+
* Skips node_modules, dist, and dot-prefixed directories. Max depth: 3.
|
|
25
|
+
*
|
|
26
|
+
* @param rootDir - Starting directory for the search.
|
|
27
|
+
* @param targetName - The package name to find.
|
|
28
|
+
* @param reader - PackageReader instance for reading package metadata.
|
|
29
|
+
* @param maxDepth - Maximum recursion depth.
|
|
30
|
+
* @returns Absolute path to the matching directory, or undefined.
|
|
25
31
|
*/
|
|
26
|
-
function findPackageDir(rootDir: string, targetName: string, maxDepth = 3): string | undefined {
|
|
32
|
+
function findPackageDir(rootDir: string, targetName: string, reader: PackageReader, maxDepth = 3): string | undefined {
|
|
27
33
|
function search(dir: string, depth: number): string | undefined {
|
|
28
34
|
if (depth > maxDepth) return undefined;
|
|
29
35
|
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
try {
|
|
33
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as PackageJson;
|
|
34
|
-
if (pkg.name === targetName) return dir;
|
|
35
|
-
} catch { /* skip invalid json */ }
|
|
36
|
-
}
|
|
36
|
+
const pkg = reader.read(dir);
|
|
37
|
+
if (pkg?.name === targetName) return dir;
|
|
37
38
|
|
|
38
39
|
let entries: string[];
|
|
39
40
|
try {
|
|
@@ -58,26 +59,24 @@ function findPackageDir(rootDir: string, targetName: string, maxDepth = 3): stri
|
|
|
58
59
|
return search(rootDir, 0);
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/** Read a package.json from a directory, returning undefined if not found */
|
|
62
|
-
function readPackageJson(dir: string): PackageJson | undefined {
|
|
63
|
-
const pkgPath = join(dir, 'package.json');
|
|
64
|
-
if (!existsSync(pkgPath)) return undefined;
|
|
65
|
-
try {
|
|
66
|
-
return JSON.parse(readFileSync(pkgPath, 'utf-8')) as PackageJson;
|
|
67
|
-
} catch {
|
|
68
|
-
return undefined;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
62
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
63
|
+
* Builds a DependencyGraph from an OctoManifest.
|
|
64
|
+
* Resolves project paths, reads package metadata via the PackageReader port,
|
|
65
|
+
* and adds edges only for dependencies that reference other workspace projects.
|
|
66
|
+
*
|
|
67
|
+
* @param manifest - The parsed octo.yaml manifest.
|
|
68
|
+
* @param rootDir - Workspace root directory.
|
|
69
|
+
* @param reader - Optional PackageReader implementation (defaults to NodePackageReader).
|
|
70
|
+
* @returns A populated DependencyGraph with nodes and internal dependency edges.
|
|
75
71
|
*/
|
|
76
|
-
export function buildGraphFromManifest(
|
|
72
|
+
export function buildGraphFromManifest(
|
|
73
|
+
manifest: OctoManifest,
|
|
74
|
+
rootDir: string,
|
|
75
|
+
reader: PackageReader = new NodePackageReader(),
|
|
76
|
+
): DependencyGraph {
|
|
77
77
|
const graph = new DependencyGraph();
|
|
78
78
|
const resolvedPaths = new Map<string, string>();
|
|
79
79
|
|
|
80
|
-
// Collect all declared names (services + packages)
|
|
81
80
|
const allEntries: Array<{ name: string; path?: string; type: 'service' | 'package' }> = [];
|
|
82
81
|
|
|
83
82
|
for (const entry of manifest.services) {
|
|
@@ -89,32 +88,27 @@ export function buildGraphFromManifest(manifest: OctoManifest, rootDir: string):
|
|
|
89
88
|
allEntries.push({ ...resolved, type: 'package' });
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
// Resolve paths and add nodes
|
|
93
91
|
for (const entry of allEntries) {
|
|
94
92
|
let dir: string | undefined;
|
|
95
93
|
|
|
96
94
|
if (entry.path) {
|
|
97
95
|
dir = resolve(rootDir, entry.path);
|
|
98
96
|
} else if (isRemoteRepo(entry.name)) {
|
|
99
|
-
// org/repo format — directory is the repo name
|
|
100
97
|
dir = resolve(rootDir, extractRepoName(entry.name));
|
|
101
98
|
} else {
|
|
102
|
-
dir = findPackageDir(rootDir, entry.name);
|
|
99
|
+
dir = findPackageDir(rootDir, entry.name, reader);
|
|
103
100
|
}
|
|
104
101
|
|
|
105
|
-
if (!dir || !
|
|
102
|
+
if (!dir || !pathExistsSync(dir)) continue;
|
|
106
103
|
|
|
107
104
|
resolvedPaths.set(entry.name, dir);
|
|
108
|
-
|
|
109
|
-
graph.addNode(node);
|
|
105
|
+
graph.addNode({ name: entry.name, type: entry.type, path: dir });
|
|
110
106
|
}
|
|
111
107
|
|
|
112
|
-
// Set of all internal package names for quick lookup
|
|
113
108
|
const internalNames = new Set(resolvedPaths.keys());
|
|
114
109
|
|
|
115
|
-
// Add edges based on package.json dependencies
|
|
116
110
|
for (const [name, dir] of resolvedPaths) {
|
|
117
|
-
const pkg =
|
|
111
|
+
const pkg = reader.read(dir);
|
|
118
112
|
if (!pkg) continue;
|
|
119
113
|
|
|
120
114
|
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, pathExistsSync } from 'fs-extra';
|
|
2
2
|
import { join, dirname, relative } from 'node:path';
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
4
|
|
|
@@ -88,7 +88,7 @@ export function createComposeAggregator(): ComposeAggregator {
|
|
|
88
88
|
const results: DiscoveredCompose[] = [];
|
|
89
89
|
for (const svcPath of servicePaths) {
|
|
90
90
|
const composePath = join(svcPath, 'docker-compose.yml');
|
|
91
|
-
if (!
|
|
91
|
+
if (!pathExistsSync(composePath)) continue;
|
|
92
92
|
const raw = readFileSync(composePath, 'utf-8');
|
|
93
93
|
const content = (parse(raw) ?? {}) as DockerComposeDocument;
|
|
94
94
|
const serviceName = svcPath.split('/').pop() ?? svcPath;
|
|
@@ -16,8 +16,8 @@ export interface ComposeSmartMerger {
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Builds the LLM prompt for optimized compose merging.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* The prompt is agnostic — it describes optimization principles without
|
|
20
|
+
* referencing specific technologies or project names.
|
|
21
21
|
*
|
|
22
22
|
* @param composes - Array of discovered compose files to merge.
|
|
23
23
|
* @returns The formatted prompt string.
|
|
@@ -27,26 +27,26 @@ function buildPrompt(composes: DiscoveredCompose[]): string {
|
|
|
27
27
|
.map((c) => `--- ${c.serviceName} (${c.path}) ---\n${JSON.stringify(c.content, null, 2)}`)
|
|
28
28
|
.join('\n\n');
|
|
29
29
|
|
|
30
|
-
return `You are
|
|
30
|
+
return `You are given multiple docker-compose files from a workspace of microservices. Produce a single optimized unified docker-compose file.
|
|
31
31
|
|
|
32
|
-
Optimization
|
|
33
|
-
1. UNIFY shared infrastructure — multiple PostgreSQL, Redis, MongoDB, Elasticsearch, RabbitMQ, NATS, or similar containers MUST be consolidated into a single shared instance. Create separate databases/schemas via environment variables or init scripts, not separate containers.
|
|
34
|
-
2. UNIFY shared volumes — if multiple services mount the same type of volume (e.g. pg-data), consolidate into one.
|
|
35
|
-
3. UNIFY networks — use a single shared network unless isolation is explicitly required for security.
|
|
36
|
-
4. PRESERVE application services — each microservice container remains separate (they are distinct apps).
|
|
37
|
-
5. MERGE environment variables — when unifying databases, collect all required databases/users into the shared instance config.
|
|
38
|
-
6. AVOID port conflicts — if two services expose the same host port, remap one to an available port.
|
|
39
|
-
7. USE latest image versions when duplicates exist with different tags.
|
|
40
|
-
8. ADD healthchecks to infrastructure services (postgres, redis, etc.) if not already present.
|
|
41
|
-
9. ADD depends_on with condition: service_healthy for app services that need infrastructure.
|
|
32
|
+
Optimization principles:
|
|
42
33
|
|
|
43
|
-
|
|
34
|
+
- Infrastructure consolidation: when multiple services declare separate containers of the same database engine (e.g. multiple PostgreSQL instances), consolidate them into a single shared instance that hosts multiple databases. The same applies to message brokers, caches, search engines, and any other shared infrastructure.
|
|
35
|
+
|
|
36
|
+
- Application preservation: each application service (containers with a "build" directive) must remain as a separate container. Never merge application services together.
|
|
37
|
+
|
|
38
|
+
- Dependency correctness: update all environment variables in application services to point to the consolidated infrastructure container names. Ensure depends_on references the correct unified containers with condition: service_healthy.
|
|
39
|
+
|
|
40
|
+
- Minimal resource footprint: use one shared network, one volume per infrastructure type, and eliminate any redundant declarations.
|
|
41
|
+
|
|
42
|
+
- Healthchecks: every infrastructure container must have a healthcheck defined.
|
|
43
|
+
|
|
44
|
+
- Port conflicts: if consolidation would create port conflicts on the host, remap to available ports.
|
|
44
45
|
|
|
45
46
|
Input compose files:
|
|
46
47
|
${composesText}
|
|
47
48
|
|
|
48
|
-
Return ONLY valid JSON with this
|
|
49
|
-
{"services": {...}, "networks": {...}, "volumes": {...}}`
|
|
49
|
+
Return ONLY valid JSON with this structure: {"services": {...}, "networks": {...}, "volumes": {...}}`
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export function createComposeSmartMerger(): ComposeSmartMerger {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readJsonSync, pathExistsSync } from 'fs-extra';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { PackageReader, PackageMetadata } from '../ports/package-reader.port.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Adapter — reads Node.js package.json files from the filesystem.
|
|
7
|
+
* Implements the PackageReader port using fs-extra for robust file handling.
|
|
8
|
+
*/
|
|
9
|
+
export class NodePackageReader implements PackageReader {
|
|
10
|
+
/**
|
|
11
|
+
* Reads and parses a package.json from the given directory.
|
|
12
|
+
*
|
|
13
|
+
* @param dir - Absolute path to the project directory.
|
|
14
|
+
* @returns Parsed package metadata, or undefined if package.json is missing or invalid.
|
|
15
|
+
*/
|
|
16
|
+
read(dir: string): PackageMetadata | undefined {
|
|
17
|
+
const pkgPath = join(dir, 'package.json');
|
|
18
|
+
if (!pathExistsSync(pkgPath)) return undefined;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const raw = readJsonSync(pkgPath);
|
|
22
|
+
return {
|
|
23
|
+
name: raw.name,
|
|
24
|
+
version: raw.version,
|
|
25
|
+
dependencies: raw.dependencies ?? {},
|
|
26
|
+
devDependencies: raw.devDependencies ?? {},
|
|
27
|
+
};
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, statSync,
|
|
1
|
+
import { readdirSync, readFileSync, statSync, pathExistsSync } from 'fs-extra';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
3
|
import { ManifestError } from '../shared/errors.js';
|
|
4
4
|
import { logger } from '../shared/logger.js';
|
|
@@ -29,7 +29,7 @@ function scanForManifests(dir: string, rootDir: string, depth: number): Discover
|
|
|
29
29
|
const results: DiscoveredManifest[] = [];
|
|
30
30
|
const manifestPath = join(dir, MANIFEST_FILENAME);
|
|
31
31
|
|
|
32
|
-
if (
|
|
32
|
+
if (pathExistsSync(manifestPath)) {
|
|
33
33
|
const dirName = relative(rootDir, dir) || '.';
|
|
34
34
|
results.push({
|
|
35
35
|
name: dirName === '.' ? 'root' : dirName,
|
|
@@ -147,7 +147,7 @@ export function displayDiscoveredProjects(rootDir: string): DiscoveredManifest[]
|
|
|
147
147
|
* Detects whether a project directory is a service (has Dockerfile) or a package.
|
|
148
148
|
*/
|
|
149
149
|
export function detectProjectType(projectDir: string): 'service' | 'package' {
|
|
150
|
-
return
|
|
150
|
+
return pathExistsSync(join(projectDir, 'Dockerfile')) ? 'service' : 'package';
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
/**
|
|
@@ -155,7 +155,7 @@ export function detectProjectType(projectDir: string): 'service' | 'package' {
|
|
|
155
155
|
*/
|
|
156
156
|
export function resolveProjectName(projectDir: string, fallbackName: string): string {
|
|
157
157
|
const pkgPath = join(projectDir, 'package.json');
|
|
158
|
-
if (
|
|
158
|
+
if (pathExistsSync(pkgPath)) {
|
|
159
159
|
try {
|
|
160
160
|
const content = readFileSync(pkgPath, 'utf-8');
|
|
161
161
|
const pkg = JSON.parse(content);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync,
|
|
1
|
+
import { readFileSync, writeFileSync, pathExistsSync } from 'fs-extra';
|
|
2
2
|
import { parseManifest } from './manifest-parser.js';
|
|
3
3
|
import { printManifest } from './manifest-printer.js';
|
|
4
4
|
import { OctoError } from '../shared/errors.js';
|
|
@@ -13,7 +13,7 @@ export interface LoadedManifest {
|
|
|
13
13
|
* Loads an existing octo.yaml manifest, or returns a minimal empty one.
|
|
14
14
|
*/
|
|
15
15
|
export function loadOrCreateManifest(manifestPath: string): LoadedManifest {
|
|
16
|
-
if (
|
|
16
|
+
if (pathExistsSync(manifestPath)) {
|
|
17
17
|
const content = readFileSync(manifestPath, 'utf-8');
|
|
18
18
|
const parsed = parseManifest(content, manifestPath);
|
|
19
19
|
if (parsed.ok) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port — abstracts reading package metadata from a project directory.
|
|
3
|
+
* Decouples domain logic from filesystem and specific package manager formats.
|
|
4
|
+
*/
|
|
5
|
+
export interface PackageMetadata {
|
|
6
|
+
name?: string;
|
|
7
|
+
version?: string;
|
|
8
|
+
dependencies: Record<string, string>;
|
|
9
|
+
devDependencies: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PackageReader {
|
|
13
|
+
/**
|
|
14
|
+
* Reads package metadata from a project directory.
|
|
15
|
+
*
|
|
16
|
+
* @param dir - Absolute path to the project directory.
|
|
17
|
+
* @returns Package metadata, or undefined if no package manifest exists.
|
|
18
|
+
*/
|
|
19
|
+
read(dir: string): PackageMetadata | undefined;
|
|
20
|
+
}
|
package/src/shared/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readJsonSync, writeJsonSync, pathExistsSync } from 'fs-extra';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
|
|
@@ -22,9 +22,9 @@ export interface OctoConfig {
|
|
|
22
22
|
* @returns The parsed configuration.
|
|
23
23
|
*/
|
|
24
24
|
export function loadConfig(): OctoConfig {
|
|
25
|
-
if (!
|
|
25
|
+
if (!pathExistsSync(CONFIG_FILE)) return {};
|
|
26
26
|
try {
|
|
27
|
-
return
|
|
27
|
+
return readJsonSync(CONFIG_FILE) as OctoConfig;
|
|
28
28
|
} catch {
|
|
29
29
|
return {};
|
|
30
30
|
}
|
|
@@ -39,7 +39,7 @@ export function loadConfig(): OctoConfig {
|
|
|
39
39
|
export function saveConfig(partial: Partial<OctoConfig>): void {
|
|
40
40
|
const existing = loadConfig();
|
|
41
41
|
const merged = { ...existing, ...partial };
|
|
42
|
-
|
|
42
|
+
writeJsonSync(CONFIG_FILE, merged, { spaces: 2 });
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
package/src/shared/sync.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { pathExistsSync } from 'fs-extra';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { logger } from './logger.js';
|
|
4
4
|
import { isRemoteRepo, resolveGitUrl, extractRepoName, cloneRepository } from './git.js';
|
|
@@ -20,7 +20,7 @@ export async function ensureRepositories(manifest: OctoManifest, rootDir: string
|
|
|
20
20
|
if (!isRemoteRepo(name)) return false;
|
|
21
21
|
const repoName = extractRepoName(name);
|
|
22
22
|
const targetDir = resolve(rootDir, explicitPath ?? repoName);
|
|
23
|
-
return !
|
|
23
|
+
return !pathExistsSync(targetDir);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
if (pending.length === 0) return 0;
|