forked from toolshed/abra
refactor: urfave v3
This commit is contained in:
@ -1,27 +1,26 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var recipeDiffCommand = cli.Command{
|
||||
Name: "diff",
|
||||
Usage: "Show unstaged changes in recipe config",
|
||||
Description: "This command requires /usr/bin/git.",
|
||||
Aliases: []string{"d"},
|
||||
ArgsUsage: "<recipe>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
r := internal.ValidateRecipe(c)
|
||||
Name: "diff",
|
||||
Usage: "Show unstaged changes in recipe config",
|
||||
Description: "This command requires /usr/bin/git.",
|
||||
Aliases: []string{"d"},
|
||||
UsageText: "abra recipe diff <recipe> [options]",
|
||||
Before: internal.SubCommandBefore,
|
||||
ShellComplete: autocomplete.RecipeNameComplete,
|
||||
HideHelp: true,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
r := internal.ValidateRecipe(cmd)
|
||||
|
||||
if err := gitPkg.DiffUnstaged(r.Dir); err != nil {
|
||||
log.Fatal(err)
|
||||
|
@ -1,32 +1,30 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var recipeFetchCommand = cli.Command{
|
||||
Name: "fetch",
|
||||
Usage: "Fetch recipe(s)",
|
||||
Aliases: []string{"f"},
|
||||
ArgsUsage: "[<recipe>]",
|
||||
Description: "Retrieves all recipes if no <recipe> argument is passed",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.OfflineFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipeName := c.Args().First()
|
||||
Name: "fetch",
|
||||
Usage: "Fetch recipe(s)",
|
||||
Aliases: []string{"f"},
|
||||
UsageText: "abra recipe fetch [<recipe>] [options]",
|
||||
Description: "Retrieves all recipes if no <recipe> argument is passed",
|
||||
Before: internal.SubCommandBefore,
|
||||
ShellComplete: autocomplete.RecipeNameComplete,
|
||||
HideHelp: true,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
recipeName := cmd.Args().First()
|
||||
r := recipe.Get(recipeName)
|
||||
if recipeName != "" {
|
||||
internal.ValidateRecipe(c)
|
||||
internal.ValidateRecipe(cmd)
|
||||
if err := r.Ensure(false, false); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
@ -8,25 +9,23 @@ import (
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/lint"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var recipeLintCommand = cli.Command{
|
||||
Name: "lint",
|
||||
Usage: "Lint a recipe",
|
||||
Aliases: []string{"l"},
|
||||
ArgsUsage: "<recipe>",
|
||||
UsageText: "abra recipe lint <recipe> [options]",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.OnlyErrorFlag,
|
||||
internal.OfflineFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.ChaosFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
Before: internal.SubCommandBefore,
|
||||
ShellComplete: autocomplete.RecipeNameComplete,
|
||||
HideHelp: true,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
recipe := internal.ValidateRecipe(cmd)
|
||||
|
||||
if err := recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
|
||||
log.Fatal(err)
|
||||
|
@ -1,6 +1,7 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
@ -10,7 +11,7 @@ import (
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var pattern string
|
||||
@ -23,17 +24,17 @@ var patternFlag = &cli.StringFlag{
|
||||
}
|
||||
|
||||
var recipeListCommand = cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List available recipes",
|
||||
Aliases: []string{"ls"},
|
||||
Name: "list",
|
||||
Usage: "List recipes",
|
||||
UsageText: "abra recipe list [options]",
|
||||
Aliases: []string{"ls"},
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.MachineReadableFlag,
|
||||
patternFlag,
|
||||
internal.OfflineFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Action: func(c *cli.Context) error {
|
||||
Before: internal.SubCommandBefore,
|
||||
HideHelp: true,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
catl, err := recipe.ReadRecipeCatalogue(internal.Offline)
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
|
@ -2,6 +2,7 @@ package recipe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@ -13,7 +14,7 @@ import (
|
||||
"coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// recipeMetadata is the recipe metadata for the README.md
|
||||
@ -34,27 +35,24 @@ var recipeNewCommand = cli.Command{
|
||||
Name: "new",
|
||||
Aliases: []string{"n"},
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.OfflineFlag,
|
||||
internal.GitNameFlag,
|
||||
internal.GitEmailFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Usage: "Create a new recipe",
|
||||
ArgsUsage: "<recipe>",
|
||||
Description: `
|
||||
Create a new recipe.
|
||||
UsageText: "abra recipe new <recipe> [options]",
|
||||
Description: `Create a new recipe.
|
||||
|
||||
Abra uses the built-in example repository which is available here:
|
||||
|
||||
https://git.coopcloud.tech/coop-cloud/example`,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipeName := c.Args().First()
|
||||
HideHelp: true,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
recipeName := cmd.Args().First()
|
||||
r := recipe.Get(recipeName)
|
||||
|
||||
if recipeName == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe name provided"))
|
||||
internal.ShowSubcommandHelpAndError(cmd, errors.New("no recipe name provided"))
|
||||
}
|
||||
|
||||
if _, err := os.Stat(r.Dir); !os.IsNotExist(err) {
|
||||
|
@ -1,7 +1,7 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// RecipeCommand defines all recipe related sub-commands.
|
||||
@ -9,17 +9,17 @@ var RecipeCommand = cli.Command{
|
||||
Name: "recipe",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "Manage recipes",
|
||||
ArgsUsage: "<recipe>",
|
||||
Description: `
|
||||
A recipe is a blueprint for an app. It is a bunch of config files which
|
||||
describe how to deploy and maintain an app. Recipes are maintained by the Co-op
|
||||
Cloud community and you can use Abra to read them, deploy them and create apps
|
||||
for you.
|
||||
UsageText: "abra recipe [command] [arguments] [options]",
|
||||
Description: `A recipe is a blueprint for an app.
|
||||
|
||||
It is a bunch of config files which describe how to deploy and maintain an app.
|
||||
Recipes are maintained by the Co-op Cloud community and you can use Abra to
|
||||
read them, deploy them and create apps for you.
|
||||
|
||||
Anyone who uses a recipe can become a maintainer. Maintainers typically make
|
||||
sure the recipe is in good working order and the config upgraded in a timely
|
||||
manner.`,
|
||||
Subcommands: []*cli.Command{
|
||||
Commands: []*cli.Command{
|
||||
&recipeFetchCommand,
|
||||
&recipeLintCommand,
|
||||
&recipeListCommand,
|
||||
|
@ -1,6 +1,7 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@ -18,17 +19,18 @@ import (
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var recipeReleaseCommand = cli.Command{
|
||||
Name: "release",
|
||||
Aliases: []string{"rl"},
|
||||
Usage: "Release a new recipe version",
|
||||
ArgsUsage: "<recipe> [<version>]",
|
||||
Description: `
|
||||
Create a new version of a recipe. These versions are then published on the
|
||||
Co-op Cloud recipe catalogue. These versions take the following form:
|
||||
UsageText: "abra recipe release <recipe> [<version>] [options]",
|
||||
Description: `Create a new version of a recipe.
|
||||
|
||||
These versions are then published on the Co-op Cloud recipe catalogue. These
|
||||
versions take the following form:
|
||||
|
||||
a.b.c+x.y.z
|
||||
|
||||
@ -46,19 +48,17 @@ Publish your new release to git.coopcloud.tech with "-p/--publish". This
|
||||
requires that you have permission to git push to these repositories and have
|
||||
your SSH keys configured on your account.`,
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.DryFlag,
|
||||
internal.MajorFlag,
|
||||
internal.MinorFlag,
|
||||
internal.PatchFlag,
|
||||
internal.PublishFlag,
|
||||
internal.OfflineFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
Before: internal.SubCommandBefore,
|
||||
ShellComplete: autocomplete.RecipeNameComplete,
|
||||
HideHelp: true,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
recipe := internal.ValidateRecipe(cmd)
|
||||
|
||||
imagesTmp, err := getImageVersions(recipe)
|
||||
if err != nil {
|
||||
@ -75,7 +75,7 @@ your SSH keys configured on your account.`,
|
||||
log.Fatalf("main app service version for %s is empty?", recipe.Name)
|
||||
}
|
||||
|
||||
tagString := c.Args().Get(1)
|
||||
tagString := cmd.Args().Get(1)
|
||||
if tagString != "" {
|
||||
if _, err := tagcmp.Parse(tagString); err != nil {
|
||||
log.Fatalf("cannot parse %s, invalid tag specified?", tagString)
|
||||
|
@ -1,32 +1,31 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var recipeResetCommand = cli.Command{
|
||||
Name: "reset",
|
||||
Usage: "Remove all unstaged changes from recipe config",
|
||||
Description: "WARNING: this will delete your changes. Be Careful.",
|
||||
Aliases: []string{"rs"},
|
||||
ArgsUsage: "<recipe>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipeName := c.Args().First()
|
||||
Name: "reset",
|
||||
Usage: "Remove all unstaged changes from recipe config",
|
||||
Description: "WARNING: this will delete your changes. Be Careful.",
|
||||
Aliases: []string{"rs"},
|
||||
UsageText: "abra recipe reset <recipe> [options]",
|
||||
Before: internal.SubCommandBefore,
|
||||
ShellComplete: autocomplete.RecipeNameComplete,
|
||||
HideHelp: true,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
recipeName := cmd.Args().First()
|
||||
r := recipe.Get(recipeName)
|
||||
|
||||
if recipeName != "" {
|
||||
internal.ValidateRecipe(c)
|
||||
internal.ValidateRecipe(cmd)
|
||||
}
|
||||
|
||||
repo, err := git.PlainOpen(r.Dir)
|
||||
|
@ -1,6 +1,7 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
@ -12,35 +13,34 @@ import (
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var recipeSyncCommand = cli.Command{
|
||||
Name: "sync",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "Sync recipe version label",
|
||||
ArgsUsage: "<recipe> [<version>]",
|
||||
UsageText: "abra recipe lint <recipe> [<version>] [options]",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.DryFlag,
|
||||
internal.MajorFlag,
|
||||
internal.MinorFlag,
|
||||
internal.PatchFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Description: `
|
||||
Generate labels for the main recipe service (i.e. by convention, the service
|
||||
named "app") which corresponds to the following format:
|
||||
Description: `Generate labels for the main recipe service.
|
||||
|
||||
By convention, the service named "app" using the following format:
|
||||
|
||||
coop-cloud.${STACK_NAME}.version=<version>
|
||||
|
||||
Where <version> can be specifed on the command-line or Abra can attempt to
|
||||
auto-generate it for you. The <recipe> configuration will be updated on the
|
||||
local file system.`,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
ShellComplete: autocomplete.RecipeNameComplete,
|
||||
HideHelp: true,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
recipe := internal.ValidateRecipe(cmd)
|
||||
|
||||
mainApp, err := internal.GetMainAppImage(recipe)
|
||||
if err != nil {
|
||||
@ -59,7 +59,7 @@ local file system.`,
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
nextTag := c.Args().Get(1)
|
||||
nextTag := cmd.Args().Get(1)
|
||||
if len(tags) == 0 && nextTag == "" {
|
||||
log.Warnf("no git tags found for %s", recipe.Name)
|
||||
if internal.NoInput {
|
||||
|
@ -2,6 +2,7 @@ package recipe
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@ -19,7 +20,7 @@ import (
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
type imgPin struct {
|
||||
@ -27,8 +28,8 @@ type imgPin struct {
|
||||
version tagcmp.Tag
|
||||
}
|
||||
|
||||
// anUpgrade represents a single service upgrade (as within a recipe), and the list of tags that it can be upgraded to,
|
||||
// for serialization purposes.
|
||||
// anUpgrade represents a single service upgrade (as within a recipe), and the
|
||||
// list of tags that it can be upgraded to, for serialization purposes.
|
||||
type anUpgrade struct {
|
||||
Service string `json:"service"`
|
||||
Image string `json:"image"`
|
||||
@ -37,13 +38,13 @@ type anUpgrade struct {
|
||||
}
|
||||
|
||||
var recipeUpgradeCommand = cli.Command{
|
||||
Name: "upgrade",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Upgrade recipe image tags",
|
||||
Description: `
|
||||
Parse all image tags within the given <recipe> configuration and prompt with
|
||||
more recent tags to upgrade to. It will update the relevant compose file tags
|
||||
on the local file system.
|
||||
Name: "upgrade",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Upgrade recipe image tags",
|
||||
UsageText: "abra recipe upgrade [<recipe>] [options]",
|
||||
Description: `Upgrade a given <recipe> configuration.
|
||||
|
||||
It will update the relevant compose file tags on the local file system.
|
||||
|
||||
Some image tags cannot be parsed because they do not follow some sort of
|
||||
semver-like convention. In this case, all possible tags will be listed and it
|
||||
@ -53,25 +54,19 @@ The command is interactive and will show a select input which allows you to
|
||||
make a seclection. Use the "?" key to see more help on navigating this
|
||||
interface.
|
||||
|
||||
You may invoke this command in "wizard" mode and be prompted for input.
|
||||
|
||||
EXAMPLE:
|
||||
|
||||
abra recipe upgrade`,
|
||||
ArgsUsage: "<recipe>",
|
||||
You may invoke this command in "wizard" mode and be prompted for input.`,
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.PatchFlag,
|
||||
internal.MinorFlag,
|
||||
internal.MajorFlag,
|
||||
internal.MachineReadableFlag,
|
||||
internal.AllTagsFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
Before: internal.SubCommandBefore,
|
||||
ShellComplete: autocomplete.RecipeNameComplete,
|
||||
HideHelp: true,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
recipe := internal.ValidateRecipe(cmd)
|
||||
|
||||
if err := recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
|
||||
log.Fatal(err)
|
||||
|
@ -1,6 +1,7 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
@ -9,7 +10,7 @@ import (
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func sortServiceByName(versions [][]string) func(i, j int) bool {
|
||||
@ -26,19 +27,17 @@ var recipeVersionCommand = cli.Command{
|
||||
Name: "versions",
|
||||
Aliases: []string{"v"},
|
||||
Usage: "List recipe versions",
|
||||
ArgsUsage: "<recipe>",
|
||||
UsageText: "abra recipe version <recipe> [options]",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.OfflineFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.MachineReadableFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
Before: internal.SubCommandBefore,
|
||||
ShellComplete: autocomplete.RecipeNameComplete,
|
||||
HideHelp: true,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
var warnMessages []string
|
||||
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
recipe := internal.ValidateRecipe(cmd)
|
||||
|
||||
catl, err := recipePkg.ReadRecipeCatalogue(internal.Offline)
|
||||
if err != nil {
|
||||
|
Reference in New Issue
Block a user