octo-dev 0.9.0 → 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.9.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"
@@ -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
  }
@@ -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.9.0');
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;
@@ -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;