Repository: kovetskiy/mark
Pull request #598 (2025-05-19 11:30:59)

Migrate to urfave/cli/v3
Migrates to urfave/cli/v3  from v2.


Patch:
diff --git a/go.mod b/go.mod
index 4c6f851..7d17c3a 100644
--- a/go.mod
+++ b/go.mod
@@ -13,18 +13,18 @@ require (
 	github.com/reconquest/pkg v1.3.1-0.20240901105413-68c2adbf2b64
 	github.com/reconquest/regexputil-go v0.0.0-20160905154124-38573e70c1f4
 	github.com/stretchr/testify v1.10.0
-	github.com/urfave/cli/v2 v2.27.6
+	github.com/urfave/cli-altsrc/v3 v3.0.1
+	github.com/urfave/cli/v3 v3.3.3
 	github.com/yuin/goldmark v1.7.12
 	golang.org/x/tools v0.33.0
 	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
-	github.com/BurntSushi/toml v1.4.0 // indirect
+	github.com/BurntSushi/toml v1.5.0 // indirect
 	github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8 // indirect
 	github.com/chromedp/chromedp v0.13.3 // indirect
 	github.com/chromedp/sysutil v1.1.0 // indirect
-	github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535 // indirect
 	github.com/gobwas/httphead v0.1.0 // indirect
@@ -33,8 +33,6 @@ require (
 	github.com/kr/pretty v0.3.1 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/reconquest/cog v0.0.0-20240830113510-c7ba12d0beeb // indirect
-	github.com/russross/blackfriday/v2 v2.1.0 // indirect
-	github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
 	github.com/zazab/zhash v0.0.0-20221031090444-2b0d50417446 // indirect
 	golang.org/x/sys v0.33.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
diff --git a/go.sum b/go.sum
index 112f9a3..dcd922b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
-github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 github.com/Shopify/toxiproxy/v2 v2.12.0 h1:d1x++lYZg/zijXPPcv7PH0MvHMzEI5aX/YuUi/Sw+yg=
 github.com/Shopify/toxiproxy/v2 v2.12.0/go.mod h1:R9Z38Pw6k2cGZWXHe7tbxjGW9azmY1KbDQJ1kd+h7Tk=
 github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
@@ -10,8 +10,6 @@ github.com/chromedp/chromedp v0.13.3 h1:c6nTn97XQBykzcXiGYL5LLebw3h3CEyrCihm4Hqu
 github.com/chromedp/chromedp v0.13.3/go.mod h1:khsDP9OP20GrowpJfZ7N05iGCwcAYxk7qf9AZBzR3Qw=
 github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
 github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
-github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
-github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -50,14 +48,12 @@ github.com/reconquest/regexputil-go v0.0.0-20160905154124-38573e70c1f4 h1:bcDXaT
 github.com/reconquest/regexputil-go v0.0.0-20160905154124-38573e70c1f4/go.mod h1:OI1di2iiFSwX3D70iZjzdmCPPfssjOl+HX40tI3VaXA=
 github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
 github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
-github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
-github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
-github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
-github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
+github.com/urfave/cli-altsrc/v3 v3.0.1 h1:v+gHk59syLk8ao9rYybZs43+D5ut/gzj0omqQ1XYl8k=
+github.com/urfave/cli-altsrc/v3 v3.0.1/go.mod h1:8UtsKKcxFVzvaoySFPfvQOk413T+IXJhaCWyyoPW3yM=
+github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I=
+github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
 github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
 github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
 github.com/zazab/zhash v0.0.0-20221031090444-2b0d50417446 h1:75pcOSsb40+ub185cJI7g5uykl9Uu76rD5ONzK/4s40=
diff --git a/main.go b/main.go
index ee71d40..faace92 100644
--- a/main.go
+++ b/main.go
@@ -1,12 +1,12 @@
 package main
 
 import (
+	"context"
 	"os"
 
 	"github.com/kovetskiy/mark/util"
 	"github.com/reconquest/pkg/log"
-	"github.com/urfave/cli/v2"
-	"github.com/urfave/cli/v2/altsrc"
+	"github.com/urfave/cli/v3"
 )
 
 const (
@@ -16,32 +16,18 @@ const (
 )
 
 func main() {
-	app := &cli.App{
+	cmd := &cli.Command{
 		Name:        "mark",
 		Usage:       usage,
 		Description: description,
 		Version:     version,
 		Flags:       util.Flags,
-		Before: altsrc.InitInputSourceWithContext(util.Flags,
-			func(context *cli.Context) (altsrc.InputSourceContext, error) {
-				if context.IsSet("config") {
-					filePath := context.String("config")
-					return altsrc.NewTomlSourceFromFile(filePath)
-				} else {
-					// Fall back to default if config is unset and path exists
-					_, err := os.Stat(util.ConfigFilePath())
-					if os.IsNotExist(err) {
-						return &altsrc.MapInputSource{}, nil
-					}
-					return altsrc.NewTomlSourceFromFile(util.ConfigFilePath())
-				}
-			}),
-		EnableBashCompletion: true,
-		HideHelpCommand:      true,
-		Action:               util.RunMark,
+		EnableShellCompletion: true,
+		HideHelpCommand:       true,
+		Action:                util.RunMark,
 	}
 
-	if err := app.Run(os.Args); err != nil {
+	if err := cmd.Run(context.TODO(), os.Args); err != nil {
 		log.Fatal(err)
 	}
 }
diff --git a/util/cli.go b/util/cli.go
index 84b3ed6..7487024 100644
--- a/util/cli.go
+++ b/util/cli.go
@@ -2,6 +2,7 @@ package util
 
 import (
 	"bytes"
+	"context"
 	"crypto/sha1"
 	"encoding/hex"
 	"fmt"
@@ -25,15 +26,15 @@ import (
 	"github.com/kovetskiy/mark/vfs"
 	"github.com/reconquest/karma-go"
 	"github.com/reconquest/pkg/log"
-	"github.com/urfave/cli/v2"
+	"github.com/urfave/cli/v3"
 )
 
-func RunMark(cCtx *cli.Context) error {
-	if err := SetLogLevel(cCtx); err != nil {
+func RunMark(ctx context.Context, cmd *cli.Command) error {
+	if err := SetLogLevel(cmd); err != nil {
 		return err
 	}
 
-	if cCtx.String("color") == "never" {
+	if cmd.String("color") == "never" {
 		log.GetLogger().SetFormat(
 			lorg.NewFormat(
 				`${time:2006-01-02 15:04:05.000} ${level:%s:left:true} ${prefix}%s`,
@@ -42,20 +43,20 @@ func RunMark(cCtx *cli.Context) error {
 		log.GetLogger().SetOutput(os.Stderr)
 	}
 
-	creds, err := GetCredentials(cCtx.String("username"), cCtx.String("password"), cCtx.String("target-url"), cCtx.String("base-url"), cCtx.Bool("compile-only"))
+	creds, err := GetCredentials(cmd.String("username"), cmd.String("password"), cmd.String("target-url"), cmd.String("base-url"), cmd.Bool("compile-only"))
 	if err != nil {
 		return err
 	}
 
 	api := confluence.NewAPI(creds.BaseURL, creds.Username, creds.Password)
 
-	files, err := doublestar.FilepathGlob(cCtx.String("files"))
+	files, err := doublestar.FilepathGlob(cmd.String("files"))
 	if err != nil {
 		return err
 	}
 	if len(files) == 0 {
 		msg := "No files matched"
-		if cCtx.Bool("ci") {
+		if cmd.Bool("ci") {
 			log.Warning(msg)
 		} else {
 			log.Fatal(msg)
@@ -63,16 +64,16 @@ func RunMark(cCtx *cli.Context) error {
 	}
 
 	log.Debug("config:")
-	for _, f := range cCtx.Command.Flags {
+	for _, f := range cmd.Flags {
 		flag := f.Names()
 		if flag[0] == "password" {
 			log.Debugf(nil, "%20s: %v", flag[0], "******")
 		} else {
-			log.Debugf(nil, "%20s: %v", flag[0], cCtx.Value(flag[0]))
+			log.Debugf(nil, "%20s: %v", flag[0], cmd.Value(flag[0]))
 		}
 	}
 
-	fatalErrorHandler := NewErrorHandler(cCtx.Bool("continue-on-error"))
+	fatalErrorHandler := NewErrorHandler(cmd.Bool("continue-on-error"))
 
 	// Loop through files matched by glob pattern
 	for _, file := range files {
@@ -82,7 +83,7 @@ func RunMark(cCtx *cli.Context) error {
 			file,
 		)
 
-		target := processFile(file, api, cCtx, creds.PageID, creds.Username, fatalErrorHandler)
+		target := processFile(file, api, cmd, creds.PageID, creds.Username, fatalErrorHandler)
 
 		if target != nil { // on dry-run or compile-only, the target is nil
 			log.Infof(
@@ -99,7 +100,7 @@ func RunMark(cCtx *cli.Context) error {
 func processFile(
 	file string,
 	api *confluence.API,
-	cCtx *cli.Context,
+	cmd *cli.Command,
 	pageID string,
 	username string,
 	fatalErrorHandler *FatalErrorHandler,
@@ -112,9 +113,9 @@ func processFile(
 
 	markdown = bytes.ReplaceAll(markdown, []byte("\r\n"), []byte("\n"))
 
-	parents := strings.Split(cCtx.String("parents"), cCtx.String("parents-delimiter"))
+	parents := strings.Split(cmd.String("parents"), cmd.String("parents-delimiter"))
 
-	meta, markdown, err := metadata.ExtractMeta(markdown, cCtx.String("space"), cCtx.Bool("title-from-h1"), parents, cCtx.Bool("title-append-generated-hash"))
+	meta, markdown, err := metadata.ExtractMeta(markdown, cmd.String("space"), cmd.Bool("title-from-h1"), parents, cmd.Bool("title-append-generated-hash"))
 	if err != nil {
 		fatalErrorHandler.Handle(err, "unable to extract metadata from file %q", file)
 		return nil
@@ -159,7 +160,7 @@ func processFile(
 	for {
 		templates, markdown, recurse, err = includes.ProcessIncludes(
 			filepath.Dir(file),
-			cCtx.String("include-path"),
+			cmd.String("include-path"),
 			markdown,
 			templates,
 		)
@@ -175,7 +176,7 @@ func processFile(
 
 	macros, markdown, err := macro.ExtractMacros(
 		filepath.Dir(file),
-		cCtx.String("include-path"),
+		cmd.String("include-path"),
 		markdown,
 		templates,
 	)
@@ -194,7 +195,7 @@ func processFile(
 		}
 	}
 
-	links, err := page.ResolveRelativeLinks(api, meta, markdown, filepath.Dir(file), cCtx.String("space"), cCtx.Bool("title-from-h1"), parents, cCtx.Bool("title-append-generated-hash"))
+	links, err := page.ResolveRelativeLinks(api, meta, markdown, filepath.Dir(file), cmd.String("space"), cmd.Bool("title-from-h1"), parents, cmd.Bool("title-append-generated-hash"))
 	if err != nil {
 		fatalErrorHandler.Handle(err, "unable to resolve relative links")
 		return nil
@@ -202,21 +203,21 @@ func processFile(
 
 	markdown = page.SubstituteLinks(markdown, links)
 
-	if cCtx.Bool("dry-run") {
-		_, _, err := page.ResolvePage(cCtx.Bool("dry-run"), api, meta)
+	if cmd.Bool("dry-run") {
+		_, _, err := page.ResolvePage(cmd.Bool("dry-run"), api, meta)
 		if err != nil {
 			fatalErrorHandler.Handle(err, "unable to resolve page location")
 			return nil
 		}
 	}
 
-	if cCtx.Bool("compile-only") || cCtx.Bool("dry-run") {
-		if cCtx.Bool("drop-h1") {
+	if cmd.Bool("compile-only") || cmd.Bool("dry-run") {
+		if cmd.Bool("drop-h1") {
 			log.Info(
 				"the leading H1 heading will be excluded from the Confluence output",
 			)
 		}
-		html, _ := mark.CompileMarkdown(markdown, stdlib, file, cCtx.String("mermaid-provider"), cCtx.Float64("mermaid-scale"), cCtx.Bool("drop-h1"), cCtx.Bool("strip-linebreaks"))
+		html, _ := mark.CompileMarkdown(markdown, stdlib, file, cmd.String("mermaid-provider"), cmd.Float("mermaid-scale"), cmd.Bool("drop-h1"), cmd.Bool("strip-linebreaks"))
 		fmt.Println(html)
 		return nil
 	}
@@ -224,7 +225,7 @@ func processFile(
 	var target *confluence.PageInfo
 
 	if meta != nil {
-		parent, page, err := page.ResolvePage(cCtx.Bool("dry-run"), api, meta)
+		parent, page, err := page.ResolvePage(cmd.Bool("dry-run"), api, meta)
 		if err != nil {
 			fatalErrorHandler.Handle(karma.Describe("title", meta.Title).Reason(err), "unable to resolve %s", meta.Type)
 			return nil
@@ -283,13 +284,13 @@ func processFile(
 
 	markdown = attachment.CompileAttachmentLinks(markdown, attaches)
 
-	if cCtx.Bool("drop-h1") {
+	if cmd.Bool("drop-h1") {
 		log.Info(
 			"the leading H1 heading will be excluded from the Confluence output",
 		)
 	}
 
-	html, inlineAttachments := mark.CompileMarkdown(markdown, stdlib, file, cCtx.String("mermaid-provider"), cCtx.Float64("mermaid-scale"), cCtx.Bool("drop-h1"), cCtx.Bool("strip-linebreaks"))
+	html, inlineAttachments := mark.CompileMarkdown(markdown, stdlib, file, cmd.String("mermaid-provider"), cmd.Float("mermaid-scale"), cmd.Bool("drop-h1"), cmd.Bool("strip-linebreaks"))
 
 	// Resolve attachements detected from markdown
 	_, err = attachment.ResolveAttachments(
@@ -329,7 +330,7 @@ func processFile(
 	var finalVersionMessage string
 	var shouldUpdatePage = true
 
-	if cCtx.Bool("changes-only") {
+	if cmd.Bool("changes-only") {
 		contentHash := getSHA1Hash(html)
 
 		log.Debugf(
@@ -360,13 +361,13 @@ func processFile(
 			}
 		}
 
-		finalVersionMessage = fmt.Sprintf("%s [v%s]", cCtx.String("version-message"), contentHash)
+		finalVersionMessage = fmt.Sprintf("%s [v%s]", cmd.String("version-message"), contentHash)
 	} else {
-		finalVersionMessage = cCtx.String("version-message")
+		finalVersionMessage = cmd.String("version-message")
 	}
 
 	if shouldUpdatePage {
-		err = api.UpdatePage(target, html, cCtx.Bool("minor-edit"), finalVersionMessage, meta.Labels, meta.ContentAppearance, meta.Emoji)
+		err = api.UpdatePage(target, html, cmd.Bool("minor-edit"), finalVersionMessage, meta.Labels, meta.ContentAppearance, meta.Emoji)
 		if err != nil {
 			fatalErrorHandler.Handle(err, "unable to update page")
 			return nil
@@ -377,7 +378,7 @@ func processFile(
 		return nil
 	}
 
-	if cCtx.Bool("edit-lock") {
+	if cmd.Bool("edit-lock") {
 		log.Infof(
 			nil,
 			`edit locked on page %q by user %q to prevent manual edits`,
@@ -467,8 +468,8 @@ func ConfigFilePath() string {
 	return filepath.Join(fp, "mark")
 }
 
-func SetLogLevel(cCtx *cli.Context) error {
-	logLevel := cCtx.String("log-level")
+func SetLogLevel(cmd *cli.Command) error {
+	logLevel := cmd.String("log-level")
 	switch strings.ToUpper(logLevel) {
 	case lorg.LevelTrace.String():
 		log.SetLevel(lorg.LevelTrace)
diff --git a/util/flags.go b/util/flags.go
index 81e673f..e7db36b 100644
--- a/util/flags.go
+++ b/util/flags.go
@@ -1,178 +1,187 @@
 package util
 
 import (
-	"github.com/urfave/cli/v2"
-	"github.com/urfave/cli/v2/altsrc"
+	altsrc "github.com/urfave/cli-altsrc/v3"
+	altsrctoml "github.com/urfave/cli-altsrc/v3/toml"
+	"github.com/urfave/cli/v3"
 )
 
+var filename = ConfigFilePath()
+
+var configFile = altsrc.NewStringPtrSourcer(&filename)
+
 var Flags = []cli.Flag{
-	altsrc.NewStringFlag(&cli.StringFlag{
+	&cli.StringFlag{
 		Name:      "files",
 		Aliases:   []string{"f"},
 		Value:     "",
 		Usage:     "use specified markdown file(s) for converting to html. Supports file globbing patterns (needs to be quoted).",
 		TakesFile: true,
-		EnvVars:   []string{"MARK_FILES"},
-	}),
-	altsrc.NewBoolFlag(&cli.BoolFlag{
+		Sources:   cli.NewValueSourceChain(cli.EnvVar("MARK_FILES"), altsrctoml.TOML("files", configFile)),
+	},
+	&cli.BoolFlag{
 		Name:    "continue-on-error",
 		Value:   false,
 		Usage:   "don't exit if an error occurs while processing a file, continue processing remaining files.",
-		EnvVars: []string{"MARK_CONTINUE_ON_ERROR"},
-	}),
-	altsrc.NewBoolFlag(&cli.BoolFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_CONTINUE_ON_ERROR"), altsrctoml.TOML("continue_on_error", configFile)),
+	},
+	&cli.BoolFlag{
 		Name:    "compile-only",
 		Value:   false,
 		Usage:   "show resulting HTML and don't update Confluence page content.",
-		EnvVars: []string{"MARK_COMPILE_ONLY"},
-	}),
-	altsrc.NewBoolFlag(&cli.BoolFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_COMPILE_ONLY"), altsrctoml.TOML("compile_only", configFile)),
+	},
+	&cli.BoolFlag{
 		Name:    "dry-run",
 		Value:   false,
 		Usage:   "resolve page and ancestry, show resulting HTML and exit.",
-		EnvVars: []string{"MARK_DRY_RUN"},
-	}),
-	altsrc.NewBoolFlag(&cli.BoolFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_DRY_RUN"), altsrctoml.TOML("dry_run", configFile)),
+	},
+	&cli.BoolFlag{
 		Name:    "edit-lock",
 		Value:   false,
 		Aliases: []string{"k"},
 		Usage:   "lock page editing to current user only to prevent accidental manual edits over Confluence Web UI.",
-		EnvVars: []string{"MARK_EDIT_LOCK"},
-	}),
-	altsrc.NewBoolFlag(&cli.BoolFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_EDIT_LOCK"), altsrctoml.TOML("edit_lock", configFile)),
+	},
+	&cli.BoolFlag{
 		Name:    "drop-h1",
 		Value:   false,
 		Aliases: []string{"h1_drop"},
 		Usage:   "don't include the first H1 heading in Confluence output.",
-		EnvVars: []string{"MARK_H1_DROP"},
-	}),
-	altsrc.NewBoolFlag(&cli.BoolFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_H1_DROP"), altsrctoml.TOML("drop_h1", configFile)),
+	},
+	&cli.BoolFlag{
 		Name:    "strip-linebreaks",
 		Value:   false,
 		Aliases: []string{"L"},
 		Usage:   "remove linebreaks inside of tags, to accomodate non-standard Confluence behavior",
-		EnvVars: []string{"MARK_STRIP_LINEBREAKS"},
-	}),
-	altsrc.NewBoolFlag(&cli.BoolFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_STRIP_LINEBREAKS"), altsrctoml.TOML("strip_linebreaks", configFile)),
+	},
+	&cli.BoolFlag{
 		Name:    "title-from-h1",
 		Value:   false,
 		Aliases: []string{"h1_title"},
 		Usage:   "extract page title from a leading H1 heading. If no H1 heading on a page exists, then title must be set in the page metadata.",
-		EnvVars: []string{"MARK_H1_TITLE"},
-	}),
-	altsrc.NewBoolFlag(&cli.BoolFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_H1_TITLE"), altsrctoml.TOML("title_from_h1", configFile)),
+	},
+	&cli.BoolFlag{
 		Name:    "title-append-generated-hash",
 		Value:   false,
 		Usage:   "appends a short hash generated from the path of the page (space, parents, and title) to the title",
-		EnvVars: []string{"MARK_TITLE_APPEND_GENERATED_HASH"},
-	}),
-	altsrc.NewBoolFlag(&cli.BoolFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_TITLE_APPEND_GENERATED_HASH"), altsrctoml.TOML("title_append_generated_hash", configFile)),
+	},
+	&cli.BoolFlag{
 		Name:    "minor-edit",
 		Value:   false,
 		Usage:   "don't send notifications while updating Confluence page.",
-		EnvVars: []string{"MARK_MINOR_EDIT"},
-	}),
-	altsrc.NewStringFlag(&cli.StringFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_MINOR_EDIT"), altsrctoml.TOML("minor_edit", configFile)),
+	},
+	&cli.StringFlag{
 		Name:    "version-message",
 		Value:   "",
 		Usage:   "add a message to the page version, to explain the edit (default: \"\")",
-		EnvVars: []string{"MARK_VERSION_MESSAGE"},
-	}),
-	altsrc.NewStringFlag(&cli.StringFlag{
-		Name:    "color",
-		Value:   "auto",
-		Usage:   "display logs in color. Possible values: auto, never.",
-		EnvVars: []string{"MARK_COLOR"},
-	}),
-	altsrc.NewStringFlag(&cli.StringFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_VERSION_MESSAGE"), altsrctoml.TOML("version_message", configFile)),
+	},
+	&cli.StringFlag{
+		Name:  "color",
+		Value: "auto",
+		Usage: "display logs in color. Possible values: auto, never.",
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_COLOR"),
+			altsrctoml.TOML("color", configFile)),
+	},
+	&cli.StringFlag{
 		Name:    "log-level",
 		Value:   "info",
 		Usage:   "set the log level. Possible values: TRACE, DEBUG, INFO, WARNING, ERROR, FATAL.",
-		EnvVars: []string{"MARK_LOG_LEVEL"},
-	}),
-	altsrc.NewStringFlag(&cli.StringFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_LOG_LEVEL"), altsrctoml.TOML("log_level", configFile)),
+	},
+	&cli.StringFlag{
 		Name:    "username",
 		Aliases: []string{"u"},
 		Value:   "",
 		Usage:   "use specified username for updating Confluence page.",
-		EnvVars: []string{"MARK_USERNAME"},
-	}),
-	altsrc.NewStringFlag(&cli.StringFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_USERNAME"),
+			altsrctoml.TOML("username", configFile)),
+	},
+	&cli.StringFlag{
 		Name:    "password",
 		Aliases: []string{"p"},
 		Value:   "",
 		Usage:   "use specified token for updating Confluence page. Specify - as password to read password from stdin, or your Personal access token. Username is not mandatory if personal access token is provided. For more info please see: https://developer.atlassian.com/server/confluence/confluence-server-rest-api/#authentication.",
-		EnvVars: []string{"MARK_PASSWORD"},
-	}),
-	altsrc.NewStringFlag(&cli.StringFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_PASSWORD"), altsrctoml.TOML("password", configFile)),
+	},
+	&cli.StringFlag{
 		Name:    "target-url",
 		Aliases: []string{"l"},
 		Value:   "",
 		Usage:   "edit specified Confluence page. If -l is not specified, file should contain metadata (see above).",
-		EnvVars: []string{"MARK_TARGET_URL"},
-	}),
-	altsrc.NewStringFlag(&cli.StringFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_TARGET_URL"), altsrctoml.TOML("target_url", configFile)),
+	},
+	&cli.StringFlag{
 		Name:    "base-url",
 		Aliases: []string{"b", "base_url"},
 		Value:   "",
 		Usage:   "base URL for Confluence. Alternative option for base_url config field.",
-		EnvVars: []string{"MARK_BASE_URL"},
-	}),
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_BASE_URL"),
+			altsrctoml.TOML("base_url", configFile)),
+	},
 	&cli.StringFlag{
 		Name:      "config",
 		Aliases:   []string{"c"},
 		Value:     ConfigFilePath(),
 		Usage:     "use the specified configuration file.",
 		TakesFile: true,
-		EnvVars:   []string{"MARK_CONFIG"},
+		Sources:   cli.NewValueSourceChain(cli.EnvVar("MARK_CONFIG")),
+		Destination: &filename,
 	},
-	altsrc.NewBoolFlag(&cli.BoolFlag{
+	&cli.BoolFlag{
 		Name:    "ci",
 		Value:   false,
 		Usage:   "run on CI mode. It won't fail if files are not found.",
-		EnvVars: []string{"MARK_CI"},
-	}),
-	altsrc.NewStringFlag(&cli.StringFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_CI"), altsrctoml.TOML("ci", configFile)),
+	},
+	&cli.StringFlag{
 		Name:    "space",
 		Value:   "",
 		Usage:   "use specified space key. If the space key is not specified, it must be set in the page metadata.",
-		EnvVars: []string{"MARK_SPACE"},
-	}),
-	altsrc.NewStringFlag(&cli.StringFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_SPACE"), altsrctoml.TOML("space", configFile)),
+	},
+	&cli.StringFlag{
 		Name:    "parents",
 		Value:   "",
 		Usage:   "A list containing the parents of the document separated by parents-delimiter (default: '/'). These will be prepended to the ones defined in the document itself.",
-		EnvVars: []string{"MARK_PARENTS"},
-	}),
-	altsrc.NewStringFlag(&cli.StringFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_PARENTS"), altsrctoml.TOML("parents", configFile)),
+	},
+	&cli.StringFlag{
 		Name:    "parents-delimiter",
 		Value:   "/",
 		Usage:   "The delimiter used for the parents list",
-		EnvVars: []string{"MARK_PARENTS_DELIMITER"},
-	}),
-	altsrc.NewStringFlag(&cli.StringFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_PARENTS_DELIMITER"), altsrctoml.TOML("parents_delimiter", configFile)),
+	},
+	&cli.StringFlag{
 		Name:    "mermaid-provider",
 		Value:   "cloudscript",
 		Usage:   "defines the mermaid provider to use. Supported options are: cloudscript, mermaid-go.",
-		EnvVars: []string{"MARK_MERMAID_PROVIDER"},
-	}),
-	altsrc.NewFloat64Flag(&cli.Float64Flag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_MERMAID_PROVIDER"), altsrctoml.TOML("mermaid_provider", configFile)),
+	},
+	&cli.FloatFlag{
 		Name:    "mermaid-scale",
 		Value:   1.0,
 		Usage:   "defines the scaling factor for mermaid renderings.",
-		EnvVars: []string{"MARK_MERMAID_SCALE"},
-	}),
-	altsrc.NewStringFlag(&cli.StringFlag{
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_MERMAID_SCALE"), altsrctoml.TOML("mermaid_scale", configFile)),
+	},
+	&cli.StringFlag{
 		Name:      "include-path",
 		Value:     "",
 		Usage:     "Path for shared includes, used as a fallback if the include doesn't exist in the current directory.",
 		TakesFile: true,
-		EnvVars:   []string{"MARK_INCLUDE_PATH"},
-	}),
-	altsrc.NewBoolFlag(&cli.BoolFlag{
+		Sources:   cli.NewValueSourceChain(cli.EnvVar("MARK_INCLUDE_PATH"), altsrctoml.TOML("include_path", configFile)),
+	},
+	&cli.BoolFlag{
 		Name:    "changes-only",
 		Value:   false,
 		Usage:   "Avoids re-uploading pages that haven't changed since the last run.",
-		EnvVars: []string{"MARK_CHANGES_ONLY"},
-	}),
+		Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_CHANGES_ONLY"), altsrctoml.TOML("changes_only", configFile)),
+	},
 }


Test patch:
diff --git a/main_test.go b/main_test.go
index 29f6f76..a011396 100644
--- a/main_test.go
+++ b/main_test.go
@@ -1,13 +1,12 @@
 package main
 
 import (
-	"flag"
 	"testing"
 
 	"github.com/kovetskiy/mark/util"
 	"github.com/reconquest/pkg/log"
 	"github.com/stretchr/testify/assert"
-	"github.com/urfave/cli/v2"
+	"github.com/urfave/cli/v3"
 )
 
 func Test_setLogLevel(t *testing.T) {
@@ -30,11 +29,17 @@ func Test_setLogLevel(t *testing.T) {
 	}
 	for name, tt := range tests {
 		t.Run(name, func(t *testing.T) {
-			set := flag.NewFlagSet("test", flag.ContinueOnError)
-			set.String("log-level", tt.args.lvl, "")
-			cliCtx := cli.NewContext(nil, set, nil)
-
-			err := util.SetLogLevel(cliCtx)
+			cmd := &cli.Command{
+				Name: "test",
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:  "log-level",
+						Value: tt.args.lvl,
+						Usage: "set the log level. Possible values: TRACE, DEBUG, INFO, WARNING, ERROR, FATAL.",
+					},
+				},
+			}
+			err := util.SetLogLevel(cmd)
 			if tt.expectedErr != "" {
 				assert.EqualError(t, err, tt.expectedErr)
 			} else {
diff --git a/markdown/markdown_test.go b/markdown/markdown_test.go
index 8fa0fc2..85059f7 100644
--- a/markdown/markdown_test.go
+++ b/markdown/markdown_test.go
@@ -1,6 +1,7 @@
 package mark_test
 
 import (
+	"context"
 	"os"
 	"path"
 	"path/filepath"
@@ -12,8 +13,7 @@ import (
 	"github.com/kovetskiy/mark/stdlib"
 	"github.com/kovetskiy/mark/util"
 	"github.com/stretchr/testify/assert"
-	"github.com/urfave/cli/v2"
-	"github.com/urfave/cli/v2/altsrc"
+	"github.com/urfave/cli/v3"
 )
 
 func loadData(t *testing.T, filename, variant string) ([]byte, string, []byte) {
@@ -128,29 +128,15 @@ func TestCompileMarkdownStripNewlines(t *testing.T) {
 }
 
 func TestContinueOnError(t *testing.T) {
-	app := &cli.App{
-		Name:        "temp-mark",
-		Usage:       "test usage",
-		Description: "mark unit tests",
-		Version:     "TEST-VERSION",
-		Flags:       util.Flags,
-		Before: altsrc.InitInputSourceWithContext(util.Flags,
-			func(context *cli.Context) (altsrc.InputSourceContext, error) {
-				if context.IsSet("config") {
-					filePath := context.String("config")
-					return altsrc.NewTomlSourceFromFile(filePath)
-				} else {
-					// Fall back to default if config is unset and path exists
-					_, err := os.Stat(util.ConfigFilePath())
-					if os.IsNotExist(err) {
-						return &altsrc.MapInputSource{}, nil
-					}
-					return altsrc.NewTomlSourceFromFile(util.ConfigFilePath())
-				}
-			}),
-		EnableBashCompletion: true,
-		HideHelpCommand:      true,
-		Action:               util.RunMark,
+	cmd := &cli.Command{
+		Name:                  "temp-mark",
+		Usage:                 "test usage",
+		Description:           "mark unit tests",
+		Version:               "TEST-VERSION",
+		Flags:                 util.Flags,
+		EnableShellCompletion: true,
+		HideHelpCommand:       true,
+		Action:                util.RunMark,
 	}
 
 	filePath := filepath.Join("testdata", "batch-tests", "*.md")
@@ -162,6 +148,6 @@ func TestContinueOnError(t *testing.T) {
 		"--files", filePath,
 	}
 
-	err := app.Run(argList)
+	err := cmd.Run(context.TODO(), argList)
 	assert.NoError(t, err, "App should run without errors when continue-on-error is enabled")
 }