Skip to content

Commit 88629f4

Browse files
authored
feat(js): add JavaScript language support (#72)
omnibump doesn't support JS right now. There's no pipeline in `melange` for bumping JS dependencies either. This means that JS users have to manually update their package.json with error-prone scripts or direct calls to the package managers. Here we add `js` as a first-class language. JS is detected by looking for package.json. In contrast to the other supported languages, JS has multiple package managers. We support pnpm, yarn, npm and bun. The manager can be auto-detected by looking for a lock file (e.g. `pnpm-lock.yaml`), or it can be manually specified via --manager. When manually specifying, a list may be given, for cases when there is more than one manager involved (e.g. in a migration). The updater tries to make minimal edits: to preserve existing keys and formatting.
1 parent dbbf877 commit 88629f4

38 files changed

Lines changed: 1892 additions & 153 deletions

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
## Features
1010

11-
- **Multi-Language Support**: Go, Rust, and Java (Maven, Gradle)
11+
- **Multi-Language Support**: Go, Rust, Java (Maven, Gradle), and JavaScript (pnpm, yarn, npm, bun)
1212
- **Automatic Detection**: Identifies project language automatically
1313
- **Unified Configuration**: Single configuration format across all languages
1414
- **Property-Based Updates**: Smart property management for Maven
@@ -27,6 +27,11 @@
2727
| Rust | Cargo | `Cargo.lock`, `Cargo.toml` |
2828
| Java | Maven | `pom.xml` |
2929
| Java | Gradle | `build.gradle`, `build.gradle.kts` |
30+
| JavaScript | pnpm | `package.json`, `pnpm-lock.yaml` |
31+
| JavaScript | yarn | `package.json`, `yarn.lock` |
32+
| JavaScript | npm | `package.json`, `package-lock.json` |
33+
| JavaScript | bun | `package.json`, `bun.lock`, `bun.lockb` |
34+
3035

3136
## Installation
3237

cmd/omnibump/analyze.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/chainguard-dev/omnibump/pkg/languages"
2020
"github.com/chainguard-dev/omnibump/pkg/languages/golang"
2121
"github.com/chainguard-dev/omnibump/pkg/languages/java"
22+
"github.com/chainguard-dev/omnibump/pkg/languages/js"
2223
"github.com/chainguard-dev/omnibump/pkg/languages/php"
2324
"github.com/chainguard-dev/omnibump/pkg/languages/rust"
2425
"github.com/ghodss/yaml"
@@ -61,7 +62,7 @@ func analyzeCmd() *cobra.Command {
6162
Long: `Analyze a project to understand how dependencies are defined.
6263
This helps determine whether to use direct dependency patches or property updates.
6364
64-
Supports Java (Maven), Go, and Rust projects with automatic language detection.
65+
Supports Java (Maven), Go, Rust, and JavaScript projects with automatic language detection.
6566
6667
Examples:
6768
# Analyze current directory
@@ -77,7 +78,7 @@ Examples:
7778
}
7879

7980
f := cmd.Flags()
80-
f.StringVarP(&analyzeF.language, "language", "l", "auto", "language to analyze (auto, java, go, rust, or deprecated: maven)")
81+
f.StringVarP(&analyzeF.language, "language", "l", "auto", "language to analyze (auto, java, go, rust, js, or deprecated: maven)")
8182
f.StringVar(&analyzeF.outputFormat, "output", "text", "output format (text, json, yaml)")
8283
f.StringVar(&analyzeF.depsFile, "deps", "", "dependencies file to analyze strategy for")
8384
f.StringVar(&analyzeF.packages, "packages", "", "inline package list to analyze")
@@ -138,6 +139,8 @@ func runAnalyze(cmd *cobra.Command, args []string) error {
138139
projectAnalyzer = &golang.GolangAnalyzer{}
139140
case "rust":
140141
projectAnalyzer = &rust.RustAnalyzer{}
142+
case "js":
143+
projectAnalyzer = &js.JSAnalyzer{}
141144
case "php":
142145
// Get the PHP language and detect build tool
143146
phpLang := &php.PHP{}

cmd/omnibump/root.go

Lines changed: 76 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
_ "github.com/chainguard-dev/omnibump/pkg/languages/golang" // Register Go
2222
_ "github.com/chainguard-dev/omnibump/pkg/languages/java" // Register Java (Maven, Gradle, etc.)
2323
"github.com/chainguard-dev/omnibump/pkg/languages/java/maven"
24+
"github.com/chainguard-dev/omnibump/pkg/languages/js"
2425
_ "github.com/chainguard-dev/omnibump/pkg/languages/php" // Register PHP (Composer, etc.)
2526
_ "github.com/chainguard-dev/omnibump/pkg/languages/rust" // Register Rust
2627
charmlog "github.com/charmbracelet/log"
@@ -30,6 +31,7 @@ import (
3031

3132
type rootFlags struct {
3233
language string
34+
managers []string
3335
depsFile string
3436
propertiesFile string
3537
packages string
@@ -77,7 +79,8 @@ func New() *cobra.Command {
7779

7880
// Add root command flags
7981
f := cmd.Flags()
80-
f.StringVarP(&flags.language, "language", "l", "auto", "language to use (auto, java, go, rust, or deprecated: maven)")
82+
f.StringVarP(&flags.language, "language", "l", "auto", "language to use (auto, java, go, rust, js, or deprecated: maven)")
83+
f.StringSliceVar(&flags.managers, "manager", nil, "build tool(s) within a language (currently only used for js: pnpm, yarn, npm, bun). May be repeated or comma-separated to write the same overrides under more than one manager's field.")
8184
f.StringVar(&flags.depsFile, "deps", "", "dependencies file (deps.yaml, or legacy names)")
8285
f.StringVar(&flags.propertiesFile, "properties", "", "properties file (properties.yaml)")
8386
f.StringVar(&flags.packages, "packages", "", "inline package list (space-separated)")
@@ -259,67 +262,33 @@ func runUpdate(cmd *cobra.Command, _ []string) error { // args unused but requir
259262
ctx := cmd.Context()
260263
log := clog.FromContext(ctx)
261264

262-
// Validate input - require at least one input source
263-
hasFileInput := flags.depsFile != "" || flags.propertiesFile != ""
264-
hasInlineInput := flags.packages != "" || flags.replaces != "" || flags.properties != ""
265-
266-
if !hasFileInput && !hasInlineInput {
267-
return fmt.Errorf("%w: at least one of --deps, --properties, --packages, --replaces, or --props must be specified", ErrMissingInput)
268-
}
269-
270-
if flags.depsFile != "" && flags.packages != "" {
271-
return fmt.Errorf("%w: cannot use both --deps and --packages", ErrConflictingInput)
272-
}
273-
274-
if flags.propertiesFile != "" && flags.properties != "" {
275-
return fmt.Errorf("%w: cannot use both --properties (file) and --props (inline)", ErrConflictingInput)
265+
if err := validateUpdateFlags(); err != nil {
266+
return err
276267
}
277268

278-
// Load configuration
279-
var cfg *config.Config
280-
281-
if hasFileInput {
282-
var err error
283-
cfg, err = loadFileInputConfig(ctx)
284-
if err != nil {
285-
return err
286-
}
287-
} else {
288-
var err error
289-
cfg, err = loadInlineInputConfig()
290-
if err != nil {
291-
return err
292-
}
269+
cfg, err := loadUpdateConfig(ctx)
270+
if err != nil {
271+
return err
293272
}
294273

295-
// Detect language
296-
detectedLang, err := resolveLanguage(ctx, log, cfg)
274+
detectedLang, err := resolveLanguage(ctx, cfg)
297275
if err != nil {
298276
return err
299277
}
300278

301-
// Get language implementation
302279
lang, err := languages.Get(detectedLang)
303280
if err != nil {
304281
return fmt.Errorf("failed to get language implementation: %w", err)
305282
}
306283

307284
log.Infof("Using language: %s", lang.Name())
308285

309-
// Convert config to UpdateConfig
310-
updateCfg := convertToUpdateConfig(cfg)
311-
updateCfg.RootDir = flags.rootDir
312-
updateCfg.Tidy = flags.tidy
313-
updateCfg.ShowDiff = flags.showDiff
314-
updateCfg.DryRun = flags.dryRun
315-
updateCfg.ManifestFile = flags.manifestFile
286+
updateCfg := buildUpdateConfig(cfg)
316287

317-
// Perform update
318288
if err := lang.Update(ctx, updateCfg); err != nil {
319289
return fmt.Errorf("update failed: %w", err)
320290
}
321291

322-
// Validate
323292
if !flags.dryRun {
324293
if err := lang.Validate(ctx, updateCfg); err != nil {
325294
log.Warnf("Validation completed with warnings: %v", err)
@@ -330,100 +299,99 @@ func runUpdate(cmd *cobra.Command, _ []string) error { // args unused but requir
330299
return nil
331300
}
332301

333-
// resolveLanguage determines the target language from flags, manifest detection,
334-
// config overrides, and auto-detection — in that priority order.
335-
func resolveLanguage(ctx context.Context, log *clog.Logger, cfg *config.Config) (string, error) {
336-
lang := flags.language
302+
// validateUpdateFlags checks the CLI flags for mutually exclusive or missing inputs.
303+
func validateUpdateFlags() error {
304+
hasFileInput := flags.depsFile != "" || flags.propertiesFile != ""
305+
hasInlineInput := flags.packages != "" || flags.replaces != "" || flags.properties != ""
337306

338-
// Handle backward compatibility: "maven" -> "java"
339-
if lang == languageMaven {
340-
log.Warnf("Language 'maven' is deprecated, use 'java' instead")
341-
lang = languageJava
307+
if !hasFileInput && !hasInlineInput {
308+
return fmt.Errorf("%w: at least one of --deps, --properties, --packages, --replaces, or --props must be specified", ErrMissingInput)
342309
}
343310

344-
// When --manifest is set, detect language from the file content directly.
345-
if flags.manifestFile != "" && (lang == languageAuto || lang == "") {
311+
if flags.depsFile != "" && flags.packages != "" {
312+
return fmt.Errorf("%w: cannot use both --deps and --packages", ErrConflictingInput)
313+
}
314+
315+
if flags.propertiesFile != "" && flags.properties != "" {
316+
return fmt.Errorf("%w: cannot use both --properties (file) and --props (inline)", ErrConflictingInput)
317+
}
318+
319+
return nil
320+
}
321+
322+
// loadUpdateConfig loads configuration from file or inline sources based on the flags.
323+
func loadUpdateConfig(ctx context.Context) (*config.Config, error) {
324+
if flags.depsFile != "" || flags.propertiesFile != "" {
325+
return loadFileInputConfig(ctx)
326+
}
327+
return loadInlineInputConfig()
328+
}
329+
330+
// resolveLanguage determines which language implementation to use, honouring
331+
// --language, --manifest, config overrides and auto-detection.
332+
func resolveLanguage(ctx context.Context, cfg *config.Config) (string, error) {
333+
log := clog.FromContext(ctx)
334+
335+
detectedLang := normaliseLanguage(flags.language, "Language 'maven' is deprecated, use 'java' instead", log)
336+
337+
if flags.manifestFile != "" && (detectedLang == languageAuto || detectedLang == "") {
346338
ok, err := maven.IsMavenPom(flags.manifestFile)
347339
if err != nil {
348340
return "", fmt.Errorf("failed to read manifest file: %w", err)
349341
}
350342
if !ok {
351343
return "", fmt.Errorf("--manifest %q: %w", flags.manifestFile, maven.ErrNotMavenPOM)
352344
}
353-
lang = languageJava
354-
log.Infof("Detected language: %s", lang)
345+
detectedLang = languageJava
346+
log.Infof("Detected language: %s", detectedLang)
355347
}
356348

357-
if lang == languageAuto || lang == "" {
358-
detected, err := languages.DetectLanguage(ctx, flags.rootDir)
359-
if err != nil && detected == "" {
349+
if detectedLang == languageAuto || detectedLang == "" {
350+
auto, err := languages.DetectLanguage(ctx, flags.rootDir)
351+
if err != nil && auto == "" {
360352
return "", fmt.Errorf("failed to detect language: %w (try specifying --language explicitly)", err)
361353
}
362354
if err != nil {
363355
// Multiple languages detected — warn but proceed with the chosen one.
364356
log.Warnf("%v", err)
365357
}
366-
lang = detected
367-
log.Infof("Detected language: %s", lang)
358+
detectedLang = auto
359+
log.Infof("Detected language: %s", detectedLang)
368360
}
369361

370-
// Override language from config if specified
371-
if cfg.Language != "" && cfg.Language != "auto" {
372-
lang = cfg.Language
373-
// Handle backward compatibility in config too
374-
if lang == "maven" {
375-
log.Warnf("Language 'maven' in config is deprecated, use 'java' instead")
376-
lang = "java"
377-
}
362+
if cfg.Language != "" && cfg.Language != languageAuto {
363+
detectedLang = normaliseLanguage(cfg.Language, "Language 'maven' in config is deprecated, use 'java' instead", log)
378364
}
379365

380-
return lang, nil
366+
return detectedLang, nil
381367
}
382368

383-
func convertToUpdateConfig(cfg *config.Config) *languages.UpdateConfig {
384-
updateCfg := &languages.UpdateConfig{
385-
Dependencies: make([]languages.Dependency, 0, len(cfg.Packages)),
386-
Properties: make(map[string]string),
387-
Options: make(map[string]any),
388-
}
389-
390-
// Convert packages
391-
for _, pkg := range cfg.Packages {
392-
dep := languages.Dependency{
393-
Name: pkg.Name,
394-
Version: pkg.Version,
395-
Scope: pkg.Scope,
396-
Type: pkg.Type,
397-
Metadata: make(map[string]any),
398-
}
399-
400-
// Store Maven-specific fields in metadata
401-
if pkg.GroupID != "" {
402-
dep.Metadata["groupId"] = pkg.GroupID
403-
}
404-
if pkg.ArtifactID != "" {
405-
dep.Metadata["artifactId"] = pkg.ArtifactID
406-
}
407-
408-
updateCfg.Dependencies = append(updateCfg.Dependencies, dep)
369+
// normaliseLanguage applies backward-compatibility aliasing (e.g. "maven" -> "java").
370+
func normaliseLanguage(name, deprecationMsg string, log *clog.Logger) string {
371+
if name == languageMaven {
372+
log.Warnf("%s", deprecationMsg)
373+
return languageJava
409374
}
375+
return name
376+
}
410377

411-
// Convert properties
412-
for _, prop := range cfg.Properties {
413-
updateCfg.Properties[prop.Property] = prop.Value
414-
}
378+
// buildUpdateConfig converts the loaded config plus CLI flags into an UpdateConfig.
379+
func buildUpdateConfig(cfg *config.Config) *languages.UpdateConfig {
380+
updateCfg := cfg.ToUpdateConfig()
381+
updateCfg.RootDir = flags.rootDir
382+
updateCfg.Tidy = flags.tidy
383+
updateCfg.ShowDiff = flags.showDiff
384+
updateCfg.DryRun = flags.dryRun
385+
updateCfg.ManifestFile = flags.manifestFile
415386

416-
// Convert replaces (Go-specific)
417-
if len(cfg.Replaces) > 0 {
418-
for _, repl := range cfg.Replaces {
419-
dep := languages.Dependency{
420-
OldName: repl.OldName,
421-
Name: repl.Name,
422-
Version: repl.Version,
423-
Replace: true,
424-
}
425-
updateCfg.Dependencies = append(updateCfg.Dependencies, dep)
387+
// The CLI's --manager flag always wins over a value in the deps file
388+
// (which ToUpdateConfig already stamped into Options).
389+
if len(flags.managers) > 0 {
390+
managers := make([]js.Manager, len(flags.managers))
391+
for i, s := range flags.managers {
392+
managers[i] = js.Manager(s)
426393
}
394+
updateCfg.Options["manager"] = managers
427395
}
428396

429397
return updateCfg

cmd/omnibump/root_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ func TestConvertToUpdateConfig_WithManifestFile(t *testing.T) {
212212
flags.manifestFile = "/some/path/custom-pom.xml"
213213

214214
cfg := &config.Config{}
215-
updateCfg := convertToUpdateConfig(cfg)
215+
updateCfg := cfg.ToUpdateConfig()
216216
updateCfg.ManifestFile = flags.manifestFile
217217

218218
if updateCfg.ManifestFile != "/some/path/custom-pom.xml" {
@@ -228,7 +228,7 @@ func TestConvertToUpdateConfig_WithoutManifestFile(t *testing.T) {
228228
flags.manifestFile = ""
229229

230230
cfg := &config.Config{}
231-
updateCfg := convertToUpdateConfig(cfg)
231+
updateCfg := cfg.ToUpdateConfig()
232232
updateCfg.ManifestFile = flags.manifestFile
233233

234234
if updateCfg.ManifestFile != "" {
@@ -248,7 +248,7 @@ func TestConvertToUpdateConfig_WithProperties(t *testing.T) {
248248
},
249249
}
250250

251-
updateCfg := convertToUpdateConfig(cfg)
251+
updateCfg := cfg.ToUpdateConfig()
252252

253253
if len(updateCfg.Dependencies) != 1 {
254254
t.Errorf("expected 1 dependency, got %d", len(updateCfg.Dependencies))

cmd/omnibump/supported.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ func runSupported(_ *cobra.Command, _ []string) error { // Both unused but requi
6060
fmt.Println(" Build Tools:")
6161
fmt.Println(" - Composer (composer.json, composer.lock)")
6262
}
63+
if langName == "js" {
64+
fmt.Println(" Package Managers (selected via lock file or --manager):")
65+
fmt.Println(" - pnpm (pnpm-lock.yaml, writes pnpm.overrides)")
66+
fmt.Println(" - yarn (yarn.lock, writes resolutions)")
67+
fmt.Println(" - npm (package-lock.json, writes overrides)")
68+
fmt.Println(" - bun (bun.lock(b), writes overrides)")
69+
}
6370

6471
fmt.Println()
6572
}

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ require (
1313
github.com/samber/lo v1.53.0
1414
github.com/spf13/cobra v1.10.2
1515
github.com/stretchr/testify v1.11.1
16+
github.com/tidwall/gjson v1.19.0
17+
github.com/tidwall/sjson v1.2.5
1618
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
1719
golang.org/x/mod v0.35.0
1820
golang.org/x/tools v0.44.0
@@ -41,6 +43,8 @@ require (
4143
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
4244
github.com/rivo/uniseg v0.4.7 // indirect
4345
github.com/spf13/pflag v1.0.9 // indirect
46+
github.com/tidwall/match v1.1.1 // indirect
47+
github.com/tidwall/pretty v1.2.0 // indirect
4448
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
4549
golang.org/x/sync v0.20.0 // indirect
4650
golang.org/x/sys v0.43.0 // indirect

go.sum

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
6666
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
6767
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
6868
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
69+
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
70+
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
71+
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
72+
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
73+
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
74+
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
75+
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
76+
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
77+
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
6978
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
7079
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
7180
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=

0 commit comments

Comments
 (0)