Skip to content
Merged
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
55 changes: 55 additions & 0 deletions cmd/add/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"

"github.com/codefly-dev/cli/cmd/common"
modulesync "github.com/codefly-dev/cli/cmd/sync"
Expand All @@ -27,13 +28,65 @@ var ModuleCmd = &cobra.Command{
if interactive {
return fmt.Errorf("interactive mode not implemented yet")
}
if moduleSource != "" {
return addReferencedModule(args[0])
}
return addModule(args[0])
},
}

var moduleAgentInput string
var moduleSource string
var moduleWithDefault bool

// addReferencedModule registers a module by reference — pointing at an
// out-of-repo directory — instead of vendoring a copy under modules/<name>/.
// This lets a solution repo be the composition root that references the host
// and runtime modules it does not own; `codefly run` boots them alongside
// local modules because resolution flows through the reference's path override.
func addReferencedModule(name string) error {
ctx, done := common.NewContext()
defer done()

workspace, err := common.LoadWorkspace(ctx)
if err != nil {
return fmt.Errorf("cannot load workspace: %w", err)
}
if workspace.ExistsModule(name) {
return fmt.Errorf("module <%s> already exists", name)
}

source, err := filepath.Abs(moduleSource)
if err != nil {
return fmt.Errorf("cannot resolve source path %q: %w", moduleSource, err)
}
mod, err := resources.LoadModuleFromDir(ctx, source)
if err != nil {
return fmt.Errorf("cannot load referenced module at %s: %w", source, err)
}
if mod.Name != name {
return fmt.Errorf("referenced module at %s is named <%s>, not <%s>", source, mod.Name, name)
}

// Store a workspace-relative override when the source lives inside the
// workspace so the reference stays portable; fall back to the absolute
// path for the out-of-repo case, which is the whole point of a reference.
stored := source
if rel, relErr := filepath.Rel(workspace.Dir(), source); relErr == nil && filepath.IsLocal(rel) {
stored = rel
}

if err := workspace.AddModuleReference(&resources.ModuleReference{Name: name, PathOverride: &stored}); err != nil {
return fmt.Errorf("cannot add module reference: %w", err)
}
if err := workspace.Save(ctx); err != nil {
return fmt.Errorf("cannot save workspace: %w", err)
}

cli.Header(2, "Referenced module <%s> added from %s.", name, source)
return nil
}

func addModule(name string) (result error) {
ctx, done := common.NewContext()
defer done()
Expand Down Expand Up @@ -149,5 +202,7 @@ func addModule(name string) (result error) {
func init() {
ModuleCmd.PersistentFlags().BoolVarP(&interactive, "interactive", "i", false, "interactive mode")
ModuleCmd.Flags().StringVar(&moduleAgentInput, "agent", "", "Module template agent (e.g. user-management, rag)")
ModuleCmd.Flags().StringVar(&moduleSource, "source", "", "Reference an existing out-of-repo module by path instead of vendoring a copy")
ModuleCmd.MarkFlagsMutuallyExclusive("agent", "source")
ModuleCmd.Flags().BoolVar(&moduleWithDefault, "yes", false, "Skip confirmation prompts (non-interactive/MCP mode)")
}
71 changes: 71 additions & 0 deletions cmd/add/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,77 @@ func TestAddModuleRollsBackScaffoldWhoseBytesDoNotMatchPinnedSource(t *testing.T
}
}

func TestAddReferencedModuleRegistersPathWithoutVendoring(t *testing.T) {
source := t.TempDir()
writeAddTestFile(t, filepath.Join(source, "module.codefly.yaml"), "kind: module\nname: host\nservices:\n - name: api\n")
writeAddTestFile(t, filepath.Join(source, "services", "api", "service.codefly.yaml"),
"name: api\nversion: 0.0.0\nagent:\n kind: codefly:service\n name: fixture\n publisher: codefly.dev\n version: 1.0.0\nendpoints: []\n")

root := t.TempDir()
workspace := &resources.Workspace{Name: "solution", Layout: resources.LayoutKindModules}
if err := workspace.SaveToDirUnsafe(context.Background(), root); err != nil {
t.Fatal(err)
}
t.Chdir(root)

previousSource, previousDefault := moduleSource, moduleWithDefault
moduleSource, moduleWithDefault = source, true
defer func() { moduleSource, moduleWithDefault = previousSource, previousDefault }()

if err := addReferencedModule("host"); err != nil {
t.Fatal(err)
}

// No vendored copy is materialized.
if _, err := os.Stat(filepath.Join(root, "modules", "host")); !os.IsNotExist(err) {
t.Fatalf("referenced module was vendored: %v", err)
}

reloaded, err := resources.FindWorkspaceUp(context.Background())
if err != nil {
t.Fatal(err)
}
if !reloaded.ExistsModule("host") {
t.Fatal("referenced module was not registered")
}
// The reference resolves back to the out-of-repo source, which is what
// `codefly run` relies on to boot the module.
mod, err := reloaded.LoadModuleFromName(context.Background(), "host")
if err != nil {
t.Fatalf("referenced module does not resolve: %v", err)
}
if mod.Dir() != source {
t.Fatalf("resolved dir = %s, want %s", mod.Dir(), source)
}
}

func TestAddReferencedModuleRejectsNameMismatch(t *testing.T) {
source := t.TempDir()
writeAddTestFile(t, filepath.Join(source, "module.codefly.yaml"), "kind: module\nname: host\nservices: []\n")

root := t.TempDir()
workspace := &resources.Workspace{Name: "solution", Layout: resources.LayoutKindModules}
if err := workspace.SaveToDirUnsafe(context.Background(), root); err != nil {
t.Fatal(err)
}
t.Chdir(root)

previousSource, previousDefault := moduleSource, moduleWithDefault
moduleSource, moduleWithDefault = source, true
defer func() { moduleSource, moduleWithDefault = previousSource, previousDefault }()

if err := addReferencedModule("runtime"); err == nil {
t.Fatal("referencing a module under a mismatched name returned success")
}
reloaded, err := resources.FindWorkspaceUp(context.Background())
if err != nil {
t.Fatal(err)
}
if reloaded.ExistsModule("runtime") {
t.Fatal("mismatched reference was registered")
}
}

func TestResourceCommandsReturnErrorsThroughCobra(t *testing.T) {
for _, command := range []*cobra.Command{
ModuleCmd,
Expand Down
36 changes: 31 additions & 5 deletions cmd/doctor_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const (
codeProviderResolutionFailed = "provider_resolution_failed"
codePlaintextNotAllowed = "plaintext_not_allowed"
codeReferenceSchemeUnknown = "reference_scheme_unknown"
codeModuleReferenceUnresolved = "module_reference_unresolved"
codeTimeout = "timeout"
)

Expand Down Expand Up @@ -112,6 +113,8 @@ func workspaceReadiness(ctx context.Context, opts workspaceReadinessOptions) *wo
return report
}

checkReferencedModules(ctx, ws, report)

env := checkEnvironment(ws, opts.env, report)
if env == nil {
return report
Expand Down Expand Up @@ -194,6 +197,28 @@ func checkWorkspace(ctx context.Context, opts workspaceReadinessOptions, report
return ws
}

// checkReferencedModules reports every module declared by reference — a `path:`
// override pointing outside the vendored modules/<name>/ layout — and flags any
// whose target cannot be loaded. Without it an unresolved reference only
// surfaces later as an opaque "cannot load workspace services" failure; here the
// module and its resolved path are named directly. Vendored modules (no path
// override) and the implicit flat-layout module are skipped.
func checkReferencedModules(ctx context.Context, ws *resources.Workspace, report *workspaceReadinessReport) {
for _, ref := range ws.Modules {
if ref.PathOverride == nil {
continue
}
resolved := ws.ModulePath(ctx, ref)
if _, err := resources.LoadModuleFromDir(ctx, resolved); err != nil {
report.add(codeModuleReferenceUnresolved, "referenced module "+ref.Name, "fail",
fmt.Sprintf("referenced module %q does not resolve at %s: %v", ref.Name, resolved, err),
fmt.Sprintf("fix the `path:` of module %q in %s, or vendor it with `codefly sync module`", ref.Name, resources.WorkspaceConfigurationName))
continue
}
report.add("", "referenced module "+ref.Name, "ok", fmt.Sprintf("%s → %s", ref.Name, resolved), "")
}
}

func checkEnvironment(ws *resources.Workspace, name string, report *workspaceReadinessReport) *resources.Environment {
env := ws.FindEnvironment(name)
if env == nil {
Expand Down Expand Up @@ -737,11 +762,12 @@ With --json, a versioned report is printed to stdout:
remediation?}]}

Stable diagnostic codes: workspace_not_found, workspace_invalid,
environment_not_found, service_not_found, configuration_directory_missing,
configuration_missing, configuration_invalid, configuration_duplicate,
provider_not_configured, provider_executable_missing,
provider_authentication_required, provider_resolution_failed,
plaintext_not_allowed, reference_scheme_unknown, timeout. External provider
environment_not_found, service_not_found, module_reference_unresolved,
configuration_directory_missing, configuration_missing,
configuration_invalid, configuration_duplicate, provider_not_configured,
provider_executable_missing, provider_authentication_required,
provider_resolution_failed, plaintext_not_allowed, reference_scheme_unknown,
timeout. External provider
binding checks add external_provider.* codes (bindings_unreadable,
bindings_schema_unknown, and the per-binding validation codes).`,
RunE: func(cmd *cobra.Command, _ []string) error {
Expand Down
32 changes: 32 additions & 0 deletions cmd/doctor_workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,38 @@ func TestDoctorWorkspaceOutsideWorkspace(t *testing.T) {
requireCode(t, report, codeWorkspaceNotFound, "fail")
}

func TestDoctorWorkspaceReportsResolvedReferencedModule(t *testing.T) {
source := t.TempDir()
if err := os.WriteFile(filepath.Join(source, "module.codefly.yaml"), []byte("kind: module\nname: host\nservices: []\n"), 0o644); err != nil {
t.Fatal(err)
}
dir := writeTestWorkspace(t, map[string]string{
"workspace.codefly.yaml": "name: solution\nlayout: modules\nmodules:\n - name: host\n path: " + source + "\n",
})
report := runReadiness(t, workspaceReadinessOptions{dir: dir})
requireNoCode(t, report, codeModuleReferenceUnresolved)
found := false
for _, d := range report.Checks {
if d.Name == "referenced module host" && d.Status == "ok" {
found = true
}
}
if !found {
t.Fatalf("resolved referenced module not reported: %s", reportJSON(t, report))
}
}

func TestDoctorWorkspaceFlagsUnresolvedReferencedModule(t *testing.T) {
dir := writeTestWorkspace(t, map[string]string{
"workspace.codefly.yaml": "name: solution\nlayout: modules\nmodules:\n - name: host\n path: /nonexistent/host\n",
})
report := runReadiness(t, workspaceReadinessOptions{dir: dir})
requireCode(t, report, codeModuleReferenceUnresolved, "fail")
if report.Status != readinessStatusNotReady {
t.Fatalf("status = %q, want not_ready", report.Status)
}
}

func TestDoctorWorkspaceMalformedWorkspace(t *testing.T) {
dir := writeTestWorkspace(t, map[string]string{
"workspace.codefly.yaml": "name: [unclosed\n bad yaml::\n",
Expand Down
10 changes: 10 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ Add resources to the workspace.
```bash
codefly add module backend # Add a module
codefly add module saas --agent=saas-starter # Scaffold and pin a module template
codefly add module host --source=../saas-host/modules/host # Reference an out-of-repo module (no vendored copy)
codefly add service api --agent=go-grpc # Add a service with an agent
codefly add service-dependency api --dependency=backend/db # Add a service dependency
codefly add library utils # Add a library
Expand All @@ -323,6 +324,15 @@ behind. Inventory-only scaffolds may omit the base manifest and service code;
their first `sync module` treats the missing manifest as an empty base and
populates the pinned source without rerunning the agent.

`add module --source <path>` declares a module **by reference** rather than
vendoring a copy: the workspace entry records a `path:` to an out-of-repo module
directory, and `codefly run` boots it alongside local modules. This is the
composition mode for multi-repo solutions (a solution repo referencing the host
and runtime modules it does not own); it is distinct from `sync module`, which
vendors a hash-pinned base. `codefly doctor workspace` reports each referenced
module and flags an unresolved reference with the `module_reference_unresolved`
diagnostic.

**`add service` flags:**

| Flag | Description |
Expand Down
Loading