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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Complete command reference emitted by its owning command-tree generator.
docs/generated/** linguist-generated=true
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ new runbook (and its skill) whenever you do a multi-step operational task a seco
### Reference (deep dives, not step-by-step)
- **The development loop** (change → render → build → deploy, what is cached, how to force a rebuild, and how to tell a stale build from a wrong one) → [docs/development-loop.md](docs/development-loop.md)
- **All CLI commands, by category** → [docs/commands.md](docs/commands.md)
- **Every command and flag** (generated from the command tree; regenerate with `go test ./cmd -run TestCLIReferenceIsCurrent -update-reference`) → [docs/cli-reference.md](docs/cli-reference.md)
- **Every command and flag** (generated from the command tree; regenerate with `go test ./cmd -run TestCLIReferenceIsCurrent -update-reference`) → [docs/generated/cli-reference.md](docs/generated/cli-reference.md)
- **Orchestration engine** → [docs/orchestration.md](docs/orchestration.md)
- **Runnables** (what the CLI does with `runnable.codefly.yaml`, and what is deliberately not implemented yet) → [docs/runnable.md](docs/runnable.md)
- **Product-owned selections** (Core resolution, local checkouts, evidence and execution blockers) → [docs/composition-selections.md](docs/composition-selections.md)
Expand Down
13 changes: 10 additions & 3 deletions cmd/cli_reference_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import (
"github.com/spf13/pflag"
)

var updateReference = flag.Bool("update-reference", false, "rewrite docs/cli-reference.md from the command tree")
var updateReference = flag.Bool("update-reference", false, "rewrite docs/generated/cli-reference.md from the command tree")

// referencePath is the generated reference every non-hidden command appears in.
const referencePath = "../docs/cli-reference.md"
const referencePath = "../docs/generated/cli-reference.md"

// TestCLIReferenceIsCurrent is the completeness guard for the command
// documentation. docs/commands.md is narrative — it explains the chains a verb
Expand All @@ -36,9 +36,16 @@ const referencePath = "../docs/cli-reference.md"
func TestCLIReferenceIsCurrent(t *testing.T) {
generated := renderCLIReference(RootCmd)
if *updateReference {
if err := os.MkdirAll(filepath.Dir(referencePath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Clean(referencePath), []byte(generated), 0o600); err != nil {
t.Fatal(err)
}
// Retire the old generated location only after its replacement is written.
if err := os.Remove("../docs/cli-reference.md"); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
t.Log("rewrote " + referencePath)
return
}
Expand Down Expand Up @@ -82,7 +89,7 @@ func renderCLIReference(root *cobra.Command) string {
out.WriteString("Generated from the command tree by\n")
out.WriteString("`go test ./cmd -run TestCLIReferenceIsCurrent -update-reference`. Do not edit by hand:\n")
out.WriteString("a command's text lives on the command, so `--help` and this page always agree.\n\n")
out.WriteString("This page is the complete list. [commands.md](commands.md) is the narrative guide —\n")
out.WriteString("This page is the complete list. [commands.md](../commands.md) is the narrative guide —\n")
out.WriteString("what a verb is for, and the chains it takes part in.\n")
renderCommand(&out, root, "")
return out.String()
Expand Down
2 changes: 1 addition & 1 deletion cmd/generate/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func init() {
ClientCmd.Flags().StringVar(&clientModuleName, "module-name", "", "facade entry-point name (default: derived from the contract)")
ClientCmd.Flags().BoolVar(&clientNoFacade, "no-facade", false, "generate bindings only, no facade")
ClientCmd.Flags().StringVar(&clientOutput, "output", "", "output directory (default: <workspace>/libraries/<name>)")
ClientCmd.Flags().BoolVar(&clientForce, "force", false, "overwrite an existing library with a different contract digest")
ClientCmd.Flags().BoolVar(&clientForce, "force", false, "overwrite a different contract or rebuild a malformed single-contract library manifest")
ClientCmd.Flags().StringVar(&clientEndpoint, "endpoint", "", "select an endpoint when --from package:/contracts: resolves to more than one: service/endpoint (a local --from names its endpoint as its own third segment instead)")
ClientCmd.Flags().StringVar(&clientGoModule, "go-module", "", "Go module path for the generated go/ library (default: github.com/codefly-dev/<name>-go)")
ClientCmd.Flags().StringVar(&clientNpmScope, "npm-scope", "", "npm package name for the generated typescript/ library (default: @codefly-dev/<name>)")
Expand Down
10 changes: 8 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Codefly formalizes development operations as typed gRPC APIs. The CLI is the use

This page is the narrative guide: what a verb is for, and the chains it takes
part in. For the complete list of every command and flag, see
[cli-reference.md](cli-reference.md), which is generated from the command tree —
[cli-reference.md](generated/cli-reference.md), which is generated from the command tree —
a command cannot exist without appearing there, and its text is the same one
`--help` prints.

Expand Down Expand Up @@ -3485,7 +3485,7 @@ with no caller and no way to detect drift.
| `--module-name` | Facade entry-point name (default: derived from the contract's proto package) |
| `--no-facade` | Bindings only |
| `--output` | Output directory (default `<workspace>/libraries/<name>`) |
| `--force` | Overwrite an existing library whose recorded contract digest differs |
| `--force` | Overwrite a different contract, or rebuild a malformed single-contract library manifest |
| `--go-module` | Go module path for `go/` (default `github.com/codefly-dev/<name>-go`) |
| `--npm-scope` | npm package name for `typescript/` (default `@codefly-dev/<name>`) |

Expand All @@ -3497,6 +3497,12 @@ from. `library.codefly.yaml` always carries a `sources:` list (one element for
`generate client`, one per aggregated contract for `sync solution-sdk` below) so
both commands share one schema.

For one explicit contract, `--force` can also rebuild a malformed generated
manifest, including unresolved merge conflicts. Its source and version come from
that contract; only the requested, freshly generated languages are recorded.
Filesystem errors and an aggregate library's unknown version still refuse the
run. Package validation and generation must succeed before any output changes.

Every requested language is generated into scratch directories before anything
under the library is written. A run that fails in the proto companion (a
contract that does not compile, a buf dependency that does not resolve) leaves
Expand Down
2 changes: 1 addition & 1 deletion docs/development-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ edit ──▶ codefly run service local, no image build seconds
verdict before doing any expensive work and refuse with `codefly doctor
workspace`'s diagnostics, so a missing configuration fails in seconds instead of
after the images are built. Every flag of every verb is in
[commands.md](commands.md) and [cli-reference.md](cli-reference.md).
[commands.md](commands.md) and [cli-reference.md](generated/cli-reference.md).

---

Expand Down
4 changes: 2 additions & 2 deletions docs/cli-reference.md → docs/generated/cli-reference.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/go.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ Makefile and `.github/workflows/go.yml` pins together: v2.13.1 cannot read Go
`make hooks` (once per clone) points git at `.githooks`: `pre-commit` refuses a
commit whose staged Go files are not gofmt-clean, and `pre-push` runs the same
pinned golangci-lint over every package the push changes, plus the CLI reference
test when a command or `docs/cli-reference.md` changed. These are the checks that
test when a command or `docs/generated/cli-reference.md` changed. These are the checks that
otherwise bounce a PR in the merge queue twenty minutes later; `--no-verify`
skips them.
6 changes: 3 additions & 3 deletions docs/runbooks/add-a-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Add a Cobra command (or subcommand) to the CLI and wire up help, docs, and — where relevant —
MCP exposure. See [../commands.md](../commands.md) for the narrative command guide and
[../cli-reference.md](../cli-reference.md) for the generated list of every command and flag.
[../generated/cli-reference.md](../generated/cli-reference.md) for the generated list of every command and flag.

## When to use

Expand Down Expand Up @@ -90,7 +90,7 @@ provider — so the static text is the contract; keep it accurate.

Two documents, two jobs.

[../cli-reference.md](../cli-reference.md) is generated from the command tree and lists every
[../generated/cli-reference.md](../generated/cli-reference.md) is generated from the command tree and lists every
command and flag. Regenerate it — `go test ./cmd -run TestCLIReferenceIsCurrent -update-reference`
— and commit the result. `TestCLIReferenceIsCurrent` fails otherwise, naming your command: that is
the guard, and it is why a new verb can no longer ship undocumented. Its text is the `Short`,
Expand Down Expand Up @@ -131,7 +131,7 @@ go test ./cmd/...
- [ ] Registered in `cmd/root.go` (top-level) or the parent's `init()`
- [ ] Parent groups reject unknown subcommands
- [ ] `Short`/`Long`/`Example` complete and network-free
- [ ] `docs/cli-reference.md` regenerated and committed (`go test ./cmd -run TestCLIReferenceIsCurrent -update-reference`)
- [ ] `docs/generated/cli-reference.md` regenerated and committed (`go test ./cmd -run TestCLIReferenceIsCurrent -update-reference`)
- [ ] Written up in `docs/commands.md` under the right category
- [ ] MCP tool added or consciously skipped (no `deploy` verb is exposed — see step 5)
- [ ] Tests pass; `--help` and `explain` render
94 changes: 59 additions & 35 deletions pkg/generators/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package generators

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -34,6 +35,8 @@ const defaultFacadeModule = "client"
// package (no producer package to name).
const localPackageID = "local"

var errInvalidLibraryManifest = errors.New("invalid generated library manifest")

// ContractEntry is one API contract a generated library binds: an endpoint's
// catalog entry, the raw contract bytes it points to, the package/module that
// produced it, and this entry's own generation options (a services subset, a
Expand Down Expand Up @@ -152,42 +155,10 @@ func GenerateClientLibrary(ctx context.Context, req *ClientLibraryRequest) error
return fmt.Errorf("cannot aggregate an OpenAPI contract with other entries in one library")
}

existingSources, err := readExistingLibrarySources(req.Output)
existingLanguages, err := existingLibraryLanguages(req, entries)
if err != nil {
return err
}
existingByKey := make(map[[3]string]librarySource, len(existingSources))
for i := range existingSources {
s := &existingSources[i]
existingByKey[[3]string{s.Module, s.Service, s.Endpoint}] = *s
}
anyDigestChanged := false
for i := range entries {
entry := &entries[i]
key := [3]string{entry.ModuleName, entry.Endpoint.Service, entry.Endpoint.Endpoint}
prior, ok := existingByKey[key]
if !ok || prior.ContractDigest == entry.Endpoint.Digest {
continue
}
if !req.Force {
return fmt.Errorf("library %s already has %s/%s (module %s) at a different contract (digest %s, generating %s); use --force to overwrite",
req.Name, entry.Endpoint.Service, entry.Endpoint.Endpoint, entry.ModuleName, prior.ContractDigest, entry.Endpoint.Digest)
}
anyDigestChanged = true
}

// Only carry a previously-declared language forward when every entry this
// run generated is the same contract that produced it (no digest changed):
// a --force digest override means at least one contract changed, so a
// language directory this run does not touch is now stale relative to it
// and the manifest must stop vouching for it.
var existingLanguages []generatedLanguageExport
if !anyDigestChanged {
existingLanguages, err = readExistingLanguageExports(req.Output)
if err != nil {
return err
}
}

pyPackage := strings.ReplaceAll(req.Name, "-", "_")
version := libraryVersion(req.Output, entries)
Expand Down Expand Up @@ -253,6 +224,59 @@ func GenerateClientLibrary(ctx context.Context, req *ClientLibraryRequest) error
return nil
}

// existingLibraryLanguages retains language declarations only when the stored
// manifest is readable and still describes the selected contracts. Forced
// single-contract recovery discards declarations the broken manifest cannot vouch for.
func existingLibraryLanguages(req *ClientLibraryRequest, entries []ContractEntry) ([]generatedLanguageExport, error) {
existingSources, err := readExistingLibrarySources(req.Output)
rebuildManifest := req.Force && len(entries) == 1 && errors.Is(err, errInvalidLibraryManifest)
if err != nil && !rebuildManifest {
return nil, err
}
if rebuildManifest {
existingSources = nil
}
existingByKey := make(map[[3]string]librarySource, len(existingSources))
for i := range existingSources {
s := &existingSources[i]
existingByKey[[3]string{s.Module, s.Service, s.Endpoint}] = *s
}
// A malformed manifest cannot vouch for previously generated languages.
// One explicit contract supplies its replacement identity and version.
anyDigestChanged := rebuildManifest
for i := range entries {
entry := &entries[i]
key := [3]string{entry.ModuleName, entry.Endpoint.Service, entry.Endpoint.Endpoint}
prior, ok := existingByKey[key]
if !ok || prior.ContractDigest == entry.Endpoint.Digest {
continue
}
if !req.Force {
return nil, fmt.Errorf("library %s already has %s/%s (module %s) at a different contract (digest %s, generating %s); use --force to overwrite",
req.Name, entry.Endpoint.Service, entry.Endpoint.Endpoint, entry.ModuleName, prior.ContractDigest, entry.Endpoint.Digest)
}
anyDigestChanged = true
}

// Only carry a previously-declared language forward when every entry this
// run generated is the same contract that produced it (no digest changed):
// a --force digest override means at least one contract changed, so a
// language directory this run does not touch is now stale relative to it
// and the manifest must stop vouching for it.
var existingLanguages []generatedLanguageExport
if !anyDigestChanged {
existingLanguages, err = readExistingLanguageExports(req.Output)
if err != nil {
if !req.Force || len(entries) != 1 || !errors.Is(err, errInvalidLibraryManifest) {
return nil, err
}
existingLanguages = nil
}
}

return existingLanguages, nil
}

// checkContractDiamond groups entries by protobuf package and errors when a
// package is consumed at more than one contract digest — two producers (or
// two versions of one) claiming the same proto package cannot both be baked
Expand Down Expand Up @@ -767,7 +791,7 @@ func readExistingLibrarySources(outputDir string) ([]librarySource, error) {
Sources []librarySource `yaml:"sources"`
}
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("cannot parse existing %s: %w", resources.LibraryConfigurationName, err)
return nil, fmt.Errorf("cannot parse existing %s: %w: %w", resources.LibraryConfigurationName, errInvalidLibraryManifest, err)
}
return doc.Sources, nil
}
Expand All @@ -786,7 +810,7 @@ func readExistingLanguageExports(outputDir string) ([]generatedLanguageExport, e
Languages []generatedLanguageExport `yaml:"languages"`
}
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("cannot parse existing %s: %w", resources.LibraryConfigurationName, err)
return nil, fmt.Errorf("cannot parse existing %s: %w: %w", resources.LibraryConfigurationName, errInvalidLibraryManifest, err)
}
return doc.Languages, nil
}
Expand Down
82 changes: 82 additions & 0 deletions pkg/generators/client_manifest_recovery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package generators

import (
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/codefly-dev/core/composition"
"github.com/codefly-dev/core/languages"
"github.com/codefly-dev/core/resources"
)

// Metadata recovery must require an explicit single-contract force and must
// still stop before writing when the language package cannot be generated.
func TestConflictedLibraryManifestRecoveryPreservesPreflight(t *testing.T) {
for _, tc := range []struct {
name, manifest string
force bool
entries int
want string
}{
{"conflict refused", "<<<<<<< HEAD\nversion: 0.1.0\n=======\nversion: 0.2.0\n>>>>>>> branch\n", false, 1, "cannot parse existing"},
{"forced conflict reaches package guard", "<<<<<<< HEAD\nversion: 0.1.0\n=======\nversion: 0.2.0\n>>>>>>> branch\n", true, 1, "cannot set the typescript client's version"},
{"forced invalid language reaches package guard", "sources: []\nlanguages: invalid\n", true, 1, "cannot set the typescript client's version"},
{"aggregate cannot infer its version", "version: [\n", true, 2, "cannot parse existing"},
} {
t.Run(tc.name, func(t *testing.T) {
output := t.TempDir()
if err := os.WriteFile(filepath.Join(output, resources.LibraryConfigurationName), []byte(tc.manifest), 0o644); err != nil {
t.Fatal(err)
}
langDir := filepath.Join(output, "typescript")
if err := os.MkdirAll(langDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(langDir, "package.json"), []byte(`{"name":"@acme/example-client","private":true}`), 0o644); err != nil {
t.Fatal(err)
}
entries := []ContractEntry{{
Endpoint: composition.APIContractEndpoint{Service: "api", Endpoint: "connect", Kind: composition.APIContractKindProtobuf, Package: "example.v1"},
PackageVersion: "0.2.0", ModuleName: "example",
}}
if tc.entries == 2 {
entries = append(entries, ContractEntry{
Endpoint: composition.APIContractEndpoint{Service: "other", Endpoint: "connect", Kind: composition.APIContractKindProtobuf, Package: "other.v1"},
PackageVersion: "0.3.0", ModuleName: "other", As: "other",
})
}
before := snapshotTree(t, output)
err := GenerateClientLibrary(context.Background(), &ClientLibraryRequest{
Name: "example-client", Output: output, Force: tc.force,
Languages: []languages.Language{languages.TYPESCRIPT}, Entries: entries,
})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("got %v, want refusal containing %q", err, tc.want)
}
if after := snapshotTree(t, output); after != before {
t.Fatalf("a refused generation changed the library:\nbefore:\n%s\nafter:\n%s", before, after)
}
})
}
}

func TestForcedLibraryRecoveryDoesNotIgnoreFilesystemErrors(t *testing.T) {
output := t.TempDir()
if err := os.Mkdir(filepath.Join(output, resources.LibraryConfigurationName), 0o755); err != nil {
t.Fatal(err)
}
err := GenerateClientLibrary(context.Background(), &ClientLibraryRequest{
Name: "example-client", Output: output, Force: true,
Languages: []languages.Language{languages.TYPESCRIPT},
Entries: []ContractEntry{{
Endpoint: composition.APIContractEndpoint{Service: "api", Endpoint: "connect", Kind: composition.APIContractKindProtobuf},
PackageVersion: "0.2.0", ModuleName: "example",
}},
})
if err == nil || !strings.Contains(err.Error(), "is a directory") {
t.Fatalf("got %v, want the filesystem error", err)
}
}
Loading