diff --git a/cmd/add/module.go b/cmd/add/module.go index bcc30496..d4425b2c 100644 --- a/cmd/add/module.go +++ b/cmd/add/module.go @@ -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" @@ -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//. +// 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() @@ -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)") } diff --git a/cmd/add/module_test.go b/cmd/add/module_test.go index ac240cbe..7397c3f6 100644 --- a/cmd/add/module_test.go +++ b/cmd/add/module_test.go @@ -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, diff --git a/cmd/doctor_workspace.go b/cmd/doctor_workspace.go index 5738099e..3c90af15 100644 --- a/cmd/doctor_workspace.go +++ b/cmd/doctor_workspace.go @@ -42,6 +42,7 @@ const ( codeProviderResolutionFailed = "provider_resolution_failed" codePlaintextNotAllowed = "plaintext_not_allowed" codeReferenceSchemeUnknown = "reference_scheme_unknown" + codeModuleReferenceUnresolved = "module_reference_unresolved" codeTimeout = "timeout" ) @@ -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 @@ -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// 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 { @@ -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 { diff --git a/cmd/doctor_workspace_test.go b/cmd/doctor_workspace_test.go index ec9f0b69..f8060cb2 100644 --- a/cmd/doctor_workspace_test.go +++ b/cmd/doctor_workspace_test.go @@ -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", diff --git a/docs/commands.md b/docs/commands.md index bc19e50c..ce27145a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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 @@ -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 ` 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 |