Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/constructor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ export NEO4J_PASSWORD=password
uv run scp-cli scan ./repos --export neo4j
```

### Export from Neo4j

```bash
# Export from Neo4j to JSON
uv run scp-cli export-neo4j --export json -o graph.json

# Export from Neo4j to C4 diagram
uv run scp-cli export-neo4j --export c4 -o architecture.puml
```

## Commands

| Command | Description |
Expand All @@ -71,6 +81,7 @@ uv run scp-cli scan ./repos --export neo4j
| `scp-cli scan <path>` | Scan local directory |
| `scp-cli scan-github <org>` | Scan GitHub org |
| `scp-cli transform <json>` | Transform JSON to other formats |
| `scp-cli export-neo4j` | Export from Neo4j to other formats |
| `scp-cli version` | Show version |

## Export Formats
Expand Down
62 changes: 62 additions & 0 deletions packages/constructor/src/scp_constructor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,67 @@ def transform(
)


@app.command("export-neo4j")
def export_neo4j(
export_format: str = typer.Option(
..., "--export", "-e", help="Export format: json, mermaid, openc2, c4"
),
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file"),
stdout: bool = typer.Option(
False, "--stdout", help="Output to stdout instead of file"
),
neo4j_uri: Optional[str] = typer.Option(
None,
"--neo4j-uri",
envvar="NEO4J_URI",
help="Neo4j URI (e.g., bolt://localhost:7687)",
),
neo4j_user: Optional[str] = typer.Option(
None, "--neo4j-user", envvar="NEO4J_USER", help="Neo4j username"
),
neo4j_password: Optional[str] = typer.Option(
None, "--neo4j-password", envvar="NEO4J_PASSWORD", help="Neo4j password"
),
):
"""Export architecture from Neo4j to other formats.

Use this when you have previously synced SCP files to Neo4j and want to
export the graph to JSON, Mermaid, C4, or other formats without re-scanning.
"""
if not neo4j_uri:
console.print(
"[red]Error:[/] --neo4j-uri required (or set NEO4J_URI env var)"
)
raise typer.Exit(1)

user = neo4j_user or "neo4j"
password = neo4j_password or "neo4j"

console.print(f"[bold blue]Connecting to Neo4j[/] {neo4j_uri}")

try:
with Neo4jGraph(neo4j_uri, user, password) as graph:
manifests = graph.export_manifests()
console.print(f"Loaded [green]{len(manifests)}[/] systems from Neo4j")

if not manifests:
console.print("[yellow]No systems found in graph[/]")
raise typer.Exit(0)

# Create manifests list in expected format for _export_manifests
manifests_with_source = [(m, "neo4j-export") for m in manifests]

_export_manifests(
manifests_with_source,
export_format,
output,
stdout,
)
except Exception as e:
console.print(f"[red]Neo4j Error:[/] {e}")
raise typer.Exit(1)


@app.command()
def version():
"""Show version information."""
Expand All @@ -395,3 +456,4 @@ def version():

if __name__ == "__main__":
app()

31 changes: 31 additions & 0 deletions packages/constructor/src/scp_constructor/neo4j_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def sync_manifest(
s.domain = $domain,
s.otel_service_name = $otel_service_name,
s.source = $source,
s._raw_manifest = $raw_manifest,
s.created_at = datetime(),
s.updated_at = datetime()
ON MATCH SET
Expand All @@ -104,6 +105,7 @@ def sync_manifest(
s.domain = $domain,
s.otel_service_name = $otel_service_name,
s.source = $source,
s._raw_manifest = $raw_manifest,
s.updated_at = datetime()
RETURN s.urn AS urn,
CASE WHEN s.created_at = s.updated_at THEN 'created' ELSE 'updated' END AS action
Expand All @@ -121,6 +123,7 @@ def sync_manifest(
else None,
"otel_service_name": manifest.otel_service_name,
"source": source,
"raw_manifest": manifest.model_dump_json(),
},
)

Expand Down Expand Up @@ -288,3 +291,31 @@ def get_blast_radius(self, system_urn: str, depth: int = 3) -> list[dict]:
{"urn": system_urn, "depth": depth},
)
return [dict(record) for record in result]

def export_manifests(self) -> list[SCPManifest]:
"""Export all systems from Neo4j as SCPManifest objects.

Reconstructs manifests from the stored _raw_manifest JSON field,
providing perfect fidelity with the original synced manifests.

Returns:
List of reconstructed SCPManifest objects
"""
manifests: list[SCPManifest] = []

with self.driver.session(database=self.database) as session:
# Get all systems with raw manifest data
result = session.run("""
MATCH (s:System)
WHERE s._raw_manifest IS NOT NULL
RETURN s._raw_manifest AS raw_manifest
ORDER BY s.tier, s.name
""")

for record in result:
raw_json = record["raw_manifest"]
if raw_json:
manifest = SCPManifest.model_validate_json(raw_json)
manifests.append(manifest)

return manifests