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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octo-dev",
3
- "version": "0.8.1",
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"
@@ -1,5 +1,5 @@
1
- import { existsSync } from 'node:fs';
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
- * The directory is resolved automatically from the repo name (or --name override).
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 (existsSync(targetDir)) {
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.0-flash' },
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: '' },
@@ -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 on stdout as an indented adjacency list.
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
- throw sortResult.error;
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
- // Print each node followed by its dependencies indented with 2 spaces
34
- for (const name of sortResult.value) {
35
- process.stdout.write(`${name}\n`);
36
- for (const dep of graph.getDependencies(name)) {
37
- process.stdout.write(` ${dep}\n`);
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.8.1');
14
+ .version('0.10.0');
15
15
 
16
16
  program
17
17
  .command('init')
@@ -1,39 +1,40 @@
1
- import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
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
- interface PackageJson {
8
- name?: string;
9
- dependencies?: Record<string, string>;
10
- devDependencies?: Record<string, string>;
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 search for a directory containing a package.json whose `name` matches `targetName`.
24
- * Searches up to depth 3 from rootDir.
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 pkgPath = join(dir, 'package.json');
31
- if (existsSync(pkgPath)) {
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
- * Build a DependencyGraph from an OctoManifest.
74
- * Reads each service/package's package.json and adds edges only for internal dependencies.
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(manifest: OctoManifest, rootDir: string): DependencyGraph {
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 || !existsSync(dir)) continue;
102
+ if (!dir || !pathExistsSync(dir)) continue;
106
103
 
107
104
  resolvedPaths.set(entry.name, dir);
108
- const node: GraphNode = { name: entry.name, type: entry.type, path: dir };
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 = readPackageJson(dir);
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, existsSync } from 'node:fs';
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 (!existsSync(composePath)) continue;
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
- * Instructs the model to unify redundant infrastructure (databases, caches, brokers)
20
- * into shared instances while preserving application-specific services.
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 a Docker Compose optimization expert. Given multiple docker-compose files from a workspace of microservices, produce the most optimized unified compose possible.
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 goals (in priority order):
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
- Example: If service-a has postgres:16 on port 5432 and service-b has postgres:15 on port 5433, produce ONE postgres:16 container with both databases created via POSTGRES_MULTIPLE_DATABASES env or an init script volume.
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 exact structure:
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, existsSync } from 'node:fs';
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 (existsSync(manifestPath)) {
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 existsSync(join(projectDir, 'Dockerfile')) ? 'service' : 'package';
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 (existsSync(pkgPath)) {
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, existsSync } from 'node:fs';
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 (existsSync(manifestPath)) {
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
+ }
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
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 (!existsSync(CONFIG_FILE)) return {};
25
+ if (!pathExistsSync(CONFIG_FILE)) return {};
26
26
  try {
27
- return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) as OctoConfig;
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
- writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf-8');
42
+ writeJsonSync(CONFIG_FILE, merged, { spaces: 2 });
43
43
  }
44
44
 
45
45
  /**
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs';
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 !existsSync(targetDir);
23
+ return !pathExistsSync(targetDir);
24
24
  });
25
25
 
26
26
  if (pending.length === 0) return 0;