Skip to content

Commit d704635

Browse files
terassyiclaude
andauthored
feat(cue): add tomei cue update command for dependency version updates (#107)
Add a new subcommand that updates first-party tomei module dependencies in cue.mod/module.cue to the latest version from the OCI registry, eliminating the need for manual version edits or destructive re-init. - Core logic: ParseModuleFile, UpdateDeps, FormatModuleFile, WriteModuleFileAtomic, HasVendoredModules in internal/cuemod/ - ParseModuleFile delegates to verify.ParseModuleFile (deduplication) - Cobra command with --dry-run flag - Docs: usage.md, cue-ecosystem.md, README.md updated - Table-driven unit tests + mock registry integration test Signed-off-by: terashima <iscale821@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fde6ed7 commit d704635

11 files changed

Lines changed: 693 additions & 30 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ tomei init
4242
# Set up CUE module
4343
tomei cue init
4444

45+
# Update module deps to latest (after schema/preset releases)
46+
tomei cue update
47+
4548
# Write manifests, then apply
4649
tomei plan .
4750
tomei apply .

cmd/tomei/cue/cue.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ var Cmd = &cobra.Command{
1010
1111
Subcommands:
1212
init Initialize a CUE module for tomei manifests
13+
update Update tomei module dependencies to the latest version
1314
scaffold Generate a CUE manifest scaffold for a resource kind
1415
eval Evaluate CUE manifests with tomei configuration
1516
export Export CUE manifests as JSON with tomei configuration`,
1617
}
1718

1819
func init() {
1920
Cmd.AddCommand(initCmd)
21+
Cmd.AddCommand(updateCmd)
2022
Cmd.AddCommand(scaffoldCmd)
2123
Cmd.AddCommand(evalCmd)
2224
Cmd.AddCommand(exportCmd)

cmd/tomei/cue/update.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package cue
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"path/filepath"
7+
8+
"github.com/spf13/cobra"
9+
"github.com/terassyi/tomei/internal/cuemod"
10+
)
11+
12+
var updateDryRun bool
13+
14+
var updateCmd = &cobra.Command{
15+
Use: "update [dir]",
16+
Short: "Update tomei module dependencies to the latest version",
17+
Long: `Update tomei module dependencies in cue.mod/module.cue to the latest
18+
published version from the OCI registry.
19+
20+
Scans the deps block for first-party tomei.terassyi.net dependencies and
21+
updates their version to the latest available.
22+
23+
Usage:
24+
tomei cue update Update in current directory
25+
tomei cue update ./manifests Update in specified directory
26+
tomei cue update --dry-run Show what would be updated without writing`,
27+
Args: cobra.MaximumNArgs(1),
28+
RunE: runUpdate,
29+
}
30+
31+
func init() {
32+
updateCmd.Flags().BoolVar(&updateDryRun, "dry-run", false, "Show updates without writing changes")
33+
}
34+
35+
func runUpdate(cmd *cobra.Command, args []string) error {
36+
dir := "."
37+
if len(args) > 0 {
38+
dir = args[0]
39+
}
40+
41+
absDir, err := filepath.Abs(dir)
42+
if err != nil {
43+
return fmt.Errorf("failed to get absolute path: %w", err)
44+
}
45+
46+
cueModDir := filepath.Join(absDir, "cue.mod")
47+
48+
// Parse existing module.cue
49+
f, err := cuemod.ParseModuleFile(cueModDir)
50+
if err != nil {
51+
return err
52+
}
53+
54+
// Resolve latest version from OCI registry
55+
ctx := cmd.Context()
56+
if ctx == nil {
57+
ctx = context.Background()
58+
}
59+
latestVersion, err := cuemod.ResolveLatestVersion(ctx)
60+
if err != nil {
61+
return fmt.Errorf("failed to resolve latest module version: %w", err)
62+
}
63+
64+
// Update deps
65+
results, err := cuemod.UpdateDeps(f, latestVersion)
66+
if err != nil {
67+
return err
68+
}
69+
70+
// Display results
71+
for _, r := range results {
72+
if r.Updated {
73+
cmd.Printf("%s: %s -> %s\n", r.ModulePath, r.OldVersion, r.NewVersion)
74+
} else {
75+
cmd.Printf("%s: already at latest (%s)\n", r.ModulePath, r.OldVersion)
76+
}
77+
}
78+
79+
if !cuemod.AnyUpdated(results) {
80+
return nil
81+
}
82+
83+
if updateDryRun {
84+
return nil
85+
}
86+
87+
// Format and write
88+
data, err := cuemod.FormatModuleFile(f)
89+
if err != nil {
90+
return err
91+
}
92+
93+
if err := cuemod.WriteModuleFileAtomic(cueModDir, data); err != nil {
94+
return err
95+
}
96+
97+
cmd.Printf("\nUpdated %s\n", cuemod.RelativePath(absDir, filepath.Join(cueModDir, "module.cue")))
98+
99+
// Hint about vendored modules
100+
if cuemod.HasVendoredModules(absDir) {
101+
cmd.Println()
102+
cmd.Println(" Vendored modules detected. Run one of:")
103+
cmd.Println(" cue mod tidy")
104+
cmd.Println(" make vendor-cue")
105+
}
106+
107+
return nil
108+
}

docs/cue-ecosystem.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ deps: {
116116
}
117117
```
118118

119-
The `deps` version is resolved at `tomei cue init` time by querying the OCI registry (ghcr.io) for the latest published tag. This pins the exact patch version for reproducibility. To update, run `cue mod tidy`.
119+
The `deps` version is resolved at `tomei cue init` time by querying the OCI registry (ghcr.io) for the latest published tag. This pins the exact patch version for reproducibility. To update, run `tomei cue update`.
120120

121121
**tomei_platform.cue:**
122122
```cue
@@ -133,6 +133,20 @@ _headless: bool | *false @tag(headless,type=bool)
133133

134134
`tomei` provides CUE subcommands that integrate with the tomei registry and `@tag()` configuration.
135135

136+
### tomei cue update
137+
138+
Update tomei module dependencies to the latest version:
139+
140+
```bash
141+
$ tomei cue update
142+
tomei.terassyi.net@v0: v0.0.1 -> v0.0.3
143+
144+
Updated cue.mod/module.cue
145+
146+
# Preview without writing
147+
$ tomei cue update --dry-run
148+
```
149+
136150
### tomei cue scaffold
137151

138152
Generate manifest scaffolds for any resource kind:

docs/usage.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,31 @@ eval $(tomei env)
5858

5959
See [CUE Ecosystem Integration](cue-ecosystem.md) for details.
6060

61+
## tomei cue update
62+
63+
Update tomei module dependencies in `cue.mod/module.cue` to the latest published version.
64+
65+
```
66+
tomei cue update [dir] [flags]
67+
```
68+
69+
| Flag | Description |
70+
|------|-------------|
71+
| `--dry-run` | Show updates without writing changes |
72+
73+
Scans the `deps` block for first-party `tomei.terassyi.net` dependencies and updates their version to the latest available from the OCI registry.
74+
75+
```bash
76+
# Update in current directory
77+
tomei cue update
78+
79+
# Preview changes without writing
80+
tomei cue update --dry-run
81+
82+
# Update in specified directory
83+
tomei cue update ./manifests
84+
```
85+
6186
## tomei cue scaffold
6287

6388
Generate a CUE manifest scaffold for a resource kind.

internal/cuemod/init_test.go

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package cuemod
22

33
import (
44
"context"
5-
"maps"
65
"os"
76
"path/filepath"
87
"testing"
@@ -136,30 +135,8 @@ func TestRelativePath(t *testing.T) {
136135
}
137136

138137
func TestResolveLatestVersion(t *testing.T) {
139-
// Helper to build a minimal CUE module for the mock registry.
140-
buildModuleFS := func(version string) fstest.MapFS {
141-
prefix := "tomei.terassyi.net_" + version + "/"
142-
return fstest.MapFS{
143-
prefix + "cue.mod/module.cue": &fstest.MapFile{
144-
Data: []byte("module: \"tomei.terassyi.net@v0\"\nlanguage: version: \"v0.9.0\"\n"),
145-
},
146-
prefix + "schema/schema.cue": &fstest.MapFile{
147-
Data: []byte("package schema\n"),
148-
},
149-
}
150-
}
151-
152-
// Merge multiple version FSes into one.
153-
mergeFS := func(versions ...string) fstest.MapFS {
154-
merged := fstest.MapFS{}
155-
for _, v := range versions {
156-
maps.Copy(merged, buildModuleFS(v))
157-
}
158-
return merged
159-
}
160-
161138
t.Run("returns latest version from multiple", func(t *testing.T) {
162-
reg, err := modregistrytest.New(mergeFS("v0.0.1", "v0.0.2", "v0.0.3"), "")
139+
reg, err := modregistrytest.New(mergeMockModuleFS("v0.0.1", "v0.0.2", "v0.0.3"), "")
163140
require.NoError(t, err)
164141
defer reg.Close()
165142

@@ -171,7 +148,7 @@ func TestResolveLatestVersion(t *testing.T) {
171148
})
172149

173150
t.Run("returns single version", func(t *testing.T) {
174-
reg, err := modregistrytest.New(buildModuleFS("v0.0.1"), "")
151+
reg, err := modregistrytest.New(buildMockModuleFS("v0.0.1"), "")
175152
require.NoError(t, err)
176153
defer reg.Close()
177154

internal/cuemod/testhelper_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package cuemod
2+
3+
import (
4+
"maps"
5+
"testing/fstest"
6+
)
7+
8+
// buildMockModuleFS creates a minimal CUE module FS for the mock registry.
9+
func buildMockModuleFS(version string) fstest.MapFS {
10+
prefix := "tomei.terassyi.net_" + version + "/"
11+
return fstest.MapFS{
12+
prefix + "cue.mod/module.cue": &fstest.MapFile{
13+
Data: []byte("module: \"tomei.terassyi.net@v0\"\nlanguage: version: \"v0.9.0\"\n"),
14+
},
15+
prefix + "schema/schema.cue": &fstest.MapFile{
16+
Data: []byte("package schema\n"),
17+
},
18+
}
19+
}
20+
21+
// mergeMockModuleFS merges multiple version FSes into one.
22+
func mergeMockModuleFS(versions ...string) fstest.MapFS {
23+
merged := fstest.MapFS{}
24+
for _, v := range versions {
25+
maps.Copy(merged, buildMockModuleFS(v))
26+
}
27+
return merged
28+
}

internal/cuemod/update.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package cuemod
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"slices"
8+
9+
"cuelang.org/go/mod/modfile"
10+
"github.com/terassyi/tomei/internal/verify"
11+
)
12+
13+
// UpdateResult represents the result of updating a single dependency.
14+
type UpdateResult struct {
15+
ModulePath string
16+
OldVersion string
17+
NewVersion string
18+
Updated bool
19+
}
20+
21+
// ParseModuleFile reads and parses cue.mod/module.cue from the given cue.mod directory.
22+
// Unlike verify.ParseModuleFile, this returns a user-friendly error when the file is missing.
23+
func ParseModuleFile(cueModDir string) (*modfile.File, error) {
24+
f, err := verify.ParseModuleFile(cueModDir)
25+
if err != nil {
26+
return nil, err
27+
}
28+
if f == nil {
29+
return nil, fmt.Errorf("module.cue not found in %s (run 'tomei cue init' first)", cueModDir)
30+
}
31+
return f, nil
32+
}
33+
34+
// UpdateDeps updates first-party (tomei.terassyi.net) dependencies in the module file
35+
// to the given latest version. Returns the list of update results.
36+
func UpdateDeps(f *modfile.File, latestVersion string) ([]UpdateResult, error) {
37+
// Collect first-party module paths for deterministic order.
38+
var firstPartyPaths []string
39+
for modPath := range f.Deps {
40+
if verify.IsFirstParty(modPath) {
41+
firstPartyPaths = append(firstPartyPaths, modPath)
42+
}
43+
}
44+
45+
if len(firstPartyPaths) == 0 {
46+
return nil, fmt.Errorf("no first-party tomei dependencies found in module.cue")
47+
}
48+
49+
slices.Sort(firstPartyPaths)
50+
51+
var results []UpdateResult
52+
for _, modPath := range firstPartyPaths {
53+
dep := f.Deps[modPath]
54+
oldVersion := dep.Version
55+
updated := oldVersion != latestVersion
56+
if updated {
57+
dep.Version = latestVersion
58+
}
59+
results = append(results, UpdateResult{
60+
ModulePath: modPath,
61+
OldVersion: oldVersion,
62+
NewVersion: latestVersion,
63+
Updated: updated,
64+
})
65+
}
66+
67+
return results, nil
68+
}
69+
70+
// FormatModuleFile formats a parsed module file back to CUE source bytes.
71+
func FormatModuleFile(f *modfile.File) ([]byte, error) {
72+
data, err := modfile.Format(f)
73+
if err != nil {
74+
return nil, fmt.Errorf("failed to format module.cue: %w", err)
75+
}
76+
return data, nil
77+
}
78+
79+
// WriteModuleFileAtomic atomically writes module.cue in the given cue.mod directory.
80+
// It writes to a temporary file first, then renames for atomicity.
81+
func WriteModuleFileAtomic(cueModDir string, data []byte) error {
82+
moduleCuePath := filepath.Join(cueModDir, "module.cue")
83+
tmpPath := moduleCuePath + ".tmp"
84+
85+
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
86+
return fmt.Errorf("failed to write temporary module.cue: %w", err)
87+
}
88+
89+
if err := os.Rename(tmpPath, moduleCuePath); err != nil {
90+
os.Remove(tmpPath)
91+
return fmt.Errorf("failed to rename temporary module.cue: %w", err)
92+
}
93+
94+
return nil
95+
}
96+
97+
// HasVendoredModules returns true if vendored tomei modules exist under the directory.
98+
func HasVendoredModules(dir string) bool {
99+
vendorPath := filepath.Join(dir, "cue.mod", "pkg", verify.FirstPartyPrefix)
100+
info, err := os.Stat(vendorPath)
101+
return err == nil && info.IsDir()
102+
}
103+
104+
// AnyUpdated returns true if any dependency was actually updated.
105+
func AnyUpdated(results []UpdateResult) bool {
106+
return slices.ContainsFunc(results, func(r UpdateResult) bool {
107+
return r.Updated
108+
})
109+
}

0 commit comments

Comments
 (0)