diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index e057966eb..9c939b17c 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -71,8 +71,9 @@ export default withMermaid(defineConfig({ text: 'Operations', items: [ { text: 'Reverse Proxy', link: '/operations/reverse-proxy' }, - { text: 'Cross Compilation', link: '/operations/cross-compilation' }, + { text: 'Server CLI', link: '/operations/server-cli' }, { text: 'Import / Export', link: '/operations/import-export' }, + { text: 'Cross Compilation', link: '/operations/cross-compilation' }, ], }, { diff --git a/docs/operations/import-export.md b/docs/operations/import-export.md index 06dae7e00..6df049f27 100644 --- a/docs/operations/import-export.md +++ b/docs/operations/import-export.md @@ -17,7 +17,7 @@ plikd export /path/to/export.bin Sample output: ``` -Exporting metadata from sqlite3 plik.db to /path/to/export.bin +Exporting metadata from sqlite3 to /path/to/export.bin exported 3 users exported 5 tokens exported 142 uploads @@ -38,7 +38,7 @@ plikd import /path/to/export.bin Sample output: ``` -Importing metadata from /path/to/export.bin to postgres host=localhost... +Importing metadata from /path/to/export.bin to postgres imported 3 out of 3 uploads imported 287 out of 287 files imported 3 out of 3 users diff --git a/docs/operations/server-cli.md b/docs/operations/server-cli.md new file mode 100644 index 000000000..aea601157 --- /dev/null +++ b/docs/operations/server-cli.md @@ -0,0 +1,155 @@ +# Server CLI + +The `plikd` binary is both the Plik server and an admin CLI for managing users, tokens, uploads, and maintenance tasks. + +::: tip +All `plikd` commands load configuration using the same search order: +`--config` flag → `PLIKD_CONFIG` env → `./plikd.cfg` → `/etc/plikd.cfg` +::: + +## User Management + +Manage user accounts (local, Google, OVH, OIDC providers). + +### Create a user + +```bash +# Local user with password +plikd user create --login admin --password s3cret123 --admin + +# OAuth provider user +plikd user create --provider google --login user@gmail.com --name "John Doe" + +# With size and TTL limits +plikd user create --login bob --max-file-size 100MB --max-user-size 1GB --max-ttl 7d +``` + +| Flag | Description | +|------|-------------| +| `--provider` | Auth provider: `local` (default), `google`, `ovh`, `oidc` | +| `--login` | User login (min 4 chars) | +| `--password` | Password for local users (min 8 chars, auto-generated if omitted) | +| `--name` | Display name | +| `--email` | Email address | +| `--admin` | Grant admin privileges | +| `--max-file-size` | Per-file size limit (e.g. `100MB`, `-1` for unlimited) | +| `--max-user-size` | Total storage limit (e.g. `1GB`, `-1` for unlimited) | +| `--max-ttl` | Maximum upload TTL (e.g. `7d`, `24h`) | + +### List users + +```bash +plikd user list +``` + +### Show user details + +```bash +plikd user show --login admin +plikd user show --provider google --login user@gmail.com +``` + +### Update a user + +```bash +plikd user update --login admin --admin +plikd user update --login bob --max-file-size 500MB --max-ttl 14d +``` + +Only the specified flags are changed — all other fields are preserved. + +### Delete a user + +```bash +plikd user delete --login admin +plikd user delete --provider google --login user@gmail.com +``` + +::: warning +Deleting a user also removes **all their uploads and files** from both the metadata and data backends. +::: + +## Token Management + +Manage API tokens for authenticated uploads. + +### Create a token + +```bash +plikd token create --login admin --comment "CI/CD pipeline" +``` + +| Flag | Description | +|------|-------------| +| `--provider` | Auth provider (default: `local`) | +| `--login` | User login to create the token for | +| `--comment` | Token description | + +### List tokens + +```bash +plikd token list +``` + +### Delete a token + +```bash +plikd token delete --token xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +## File & Upload Management + +Manage uploads and individual files in the system. + +### List files + +```bash +# List all files +plikd file list + +# List files in a specific upload +plikd file list --upload abc123 + +# Show a specific file +plikd file list --file def456 + +# Machine-readable sizes (bytes) +plikd file list --human=false +``` + +### Show file details + +```bash +plikd file show --file abc123 +``` + +Displays full file metadata, upload URL, and direct download URL. + +### Delete + +You must specify exactly one of `--file`, `--upload`, or `--all`: + +```bash +# Delete a single file +plikd file delete --file abc123 + +# Delete an entire upload +plikd file delete --upload def456 + +# Delete ALL uploads (requires confirmation) +plikd file delete --all +``` + +::: danger +`--all` removes **every upload** in the system. A confirmation prompt is always shown. +::: + +## Cleanup + +Remove expired uploads and purge deleted files from the data backend: + +```bash +plikd clean +``` + +This runs the same cleanup routine that the server executes periodically when running. Use it for manual maintenance or cron jobs. diff --git a/server/ARCHITECTURE.md b/server/ARCHITECTURE.md index a3269f1cb..5b59d1932 100644 --- a/server/ARCHITECTURE.md +++ b/server/ARCHITECTURE.md @@ -29,12 +29,12 @@ The server binary `plikd` uses [cobra](https://github.com/spf13/cobra) for CLI m | File | Command | Description | |------|---------|-------------| | `root.go` | `plikd` | Start the server (default command) | -| `user.go` | `plikd user create/list/delete` | Manage local users | +| `user.go` | `plikd user create/show/update/list/delete` | Manage users | | `token.go` | `plikd token create/list/delete` | Manage user tokens | -| `file.go` | `plikd file list/delete` | Manage uploads/files | +| `file.go` | `plikd file list/show/delete` | Manage uploads/files (`delete` requires `--file`, `--upload`, or `--all`) | | `clean.go` | `plikd clean` | Run metadata cleanup | -| `import.go` | `plikd import` | Import metadata from gob + Snappy binary | -| `export.go` | `plikd export` | Export metadata to gob + Snappy binary | +| `import.go` | `plikd import [input-file]` | Import metadata from gob + Snappy binary | +| `export.go` | `plikd export [output-file]` | Export metadata to gob + Snappy binary | Config loading order: `--config` flag → `PLIKD_CONFIG` env → `./plikd.cfg` → `/etc/plikd.cfg`. diff --git a/server/cmd/clean.go b/server/cmd/clean.go index 6aad9eb9a..bc6c0b502 100644 --- a/server/cmd/clean.go +++ b/server/cmd/clean.go @@ -1,6 +1,8 @@ package cmd import ( + "fmt" + "github.com/spf13/cobra" "github.com/root-gg/plik/server/server" @@ -27,5 +29,7 @@ func clean(cmd *cobra.Command, args []string) { plik.WithDataBackend(dataBackend) // Delete expired upload and files + fmt.Println("Cleaning expired uploads and files...") plik.Clean() + fmt.Println("Cleaning completed") } diff --git a/server/cmd/export.go b/server/cmd/export.go index 780fe8c90..8be218133 100644 --- a/server/cmd/export.go +++ b/server/cmd/export.go @@ -9,7 +9,7 @@ import ( // exportCmd to export metadata var exportCmd = &cobra.Command{ - Use: "export", + Use: "export [output-file]", Short: "Export metadata", Run: exportMetadata, } @@ -26,7 +26,7 @@ func exportMetadata(cmd *cobra.Command, args []string) { initializeMetadataBackend() - fmt.Printf("Exporting metadata from %s %s to %s\n", metadataBackend.Config.Driver, metadataBackend.Config.ConnectionString, args[0]) + fmt.Printf("Exporting metadata from %s to %s\n", metadataBackend.Config.Driver, args[0]) err := metadataBackend.Export(args[0]) if err != nil { diff --git a/server/cmd/file.go b/server/cmd/file.go index e9f8d8444..309244922 100644 --- a/server/cmd/file.go +++ b/server/cmd/file.go @@ -2,9 +2,10 @@ package cmd import ( "fmt" - "github.com/root-gg/utils" "os" + "github.com/root-gg/utils" + "github.com/dustin/go-humanize" "github.com/spf13/cobra" @@ -16,6 +17,7 @@ type fileFlagParams struct { uploadID string fileID string human bool + all bool } var fileParams = fileFlagParams{} @@ -30,21 +32,32 @@ var fileCmd = &cobra.Command{ var listFilesCmd = &cobra.Command{ Use: "list", Short: "List files", - Run: listFiles, + Example: ` plikd file list + plikd file list --upload abc123 + plikd file list --file def456 + plikd file list --human=false`, + Run: listFiles, } // showFileCmd represents the "file show" command var showFileCmd = &cobra.Command{ - Use: "show", - Short: "show file info", - Run: showFile, + Use: "show", + Short: "Show file info", + Example: ` plikd file show --file abc123`, + Run: showFile, } -// deleteFilesCmd represents the "file delete" command -var deleteFilesCmd = &cobra.Command{ +// deleteFileCmd represents the "file delete" command +var deleteFileCmd = &cobra.Command{ Use: "delete", - Short: "Delete files", - Run: deleteFiles, + Short: "Delete a file, an upload, or all uploads", + Long: `Delete a file, an upload, or all uploads. + +You must specify exactly one of --file, --upload, or --all.`, + Example: ` plikd file delete --file abc123 + plikd file delete --upload def456 + plikd file delete --all`, + Run: deleteFiles, } func init() { @@ -58,7 +71,9 @@ func init() { listFilesCmd.Flags().BoolVar(&fileParams.human, "human", true, "human readable size") fileCmd.AddCommand(showFileCmd) - fileCmd.AddCommand(deleteFilesCmd) + + fileCmd.AddCommand(deleteFileCmd) + deleteFileCmd.Flags().BoolVar(&fileParams.all, "all", false, "delete ALL uploads (requires confirmation)") } func listFiles(cmd *cobra.Command, args []string) { @@ -131,6 +146,28 @@ func showFile(cmd *cobra.Command, args []string) { } func deleteFiles(cmd *cobra.Command, args []string) { + // Require exactly one of --file, --upload, or --all + flagCount := 0 + if fileParams.fileID != "" { + flagCount++ + } + if fileParams.uploadID != "" { + flagCount++ + } + if fileParams.all { + flagCount++ + } + + if flagCount == 0 { + fmt.Println("Please specify one of --file, --upload, or --all") + _ = cmd.Usage() + os.Exit(1) + } + if flagCount > 1 { + fmt.Println("Please specify only one of --file, --upload, or --all") + os.Exit(1) + } + initializeMetadataBackend() if fileParams.fileID != "" { @@ -160,6 +197,8 @@ func deleteFiles(cmd *cobra.Command, args []string) { fmt.Printf("Unable to remove file %s : %s\n", fileParams.fileID, err) os.Exit(1) } + + fmt.Printf("File %s has been removed\n", fileParams.fileID) } else if fileParams.uploadID != "" { // Ask confirmation @@ -175,10 +214,12 @@ func deleteFiles(cmd *cobra.Command, args []string) { err = metadataBackend.RemoveUpload(fileParams.uploadID) if err != nil { - fmt.Printf("Unable to get upload files : %s\n", err) + fmt.Printf("Unable to remove upload %s : %s\n", fileParams.uploadID, err) os.Exit(1) } - } else { + + fmt.Printf("Upload %s has been removed\n", fileParams.uploadID) + } else if fileParams.all { // Ask confirmation fmt.Printf("Do you really want to remove ALL uploads ? [y/N]\n") @@ -199,14 +240,16 @@ func deleteFiles(cmd *cobra.Command, args []string) { fmt.Printf("Unable to delete uploads : %s\n", err) os.Exit(1) } + + fmt.Println("All uploads have been removed") } + // Clean data backend plik := server.NewPlikServer(config) plik.WithMetadataBackend(metadataBackend) initializeDataBackend() plik.WithDataBackend(dataBackend) - // Delete upload and files plik.Clean() } diff --git a/server/cmd/import.go b/server/cmd/import.go index f059abaf1..1db458b8d 100644 --- a/server/cmd/import.go +++ b/server/cmd/import.go @@ -17,7 +17,7 @@ var importParams = importFlagParams{} // importCmd to import metadata var importCmd = &cobra.Command{ - Use: "import", + Use: "import [input-file]", Short: "Import metadata", Run: importMetadata, } @@ -29,12 +29,13 @@ func init() { func importMetadata(cmd *cobra.Command, args []string) { if len(args) != 1 { - fmt.Println("Missing metadata export file") + fmt.Println("Missing metadata import file") + os.Exit(1) } initializeMetadataBackend() - fmt.Printf("Importing metadata from %s to %s %s\n", args[0], metadataBackend.Config.Driver, metadataBackend.Config.ConnectionString) + fmt.Printf("Importing metadata from %s to %s\n", args[0], metadataBackend.Config.Driver) importOptions := &metadata.ImportOptions{ IgnoreErrors: importParams.ignoreErrors, diff --git a/server/cmd/root.go b/server/cmd/root.go index a50dbb980..58289dcf8 100644 --- a/server/cmd/root.go +++ b/server/cmd/root.go @@ -44,7 +44,7 @@ func init() { // Here you will define your flags and configuration settings. // Cobra supports persistent flags, which, if defined here, // will be global for your application. - rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "config file (default is /etc/plikd.cfg)") + rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "config file (default: ./plikd.cfg or /etc/plikd.cfg, env: PLIKD_CONFIG)") // Cobra also supports local flags, which will only run // when this action is called directly. @@ -103,31 +103,31 @@ func initConfig() { // Initialize metadata backend var initializeMetadataBackendOnce sync.Once var metadataBackend *metadata.Backend +var initMetadataBackendErr error func initializeMetadataBackend() { - var err error initializeMetadataBackendOnce.Do(func() { - metadataBackend, err = server.NewMetadataBackend(config.MetadataBackendConfig, config.NewLogger()) - if err != nil { - fmt.Printf("unable to initialize metadata backend : %s\n", err) - os.Exit(1) - } + metadataBackend, initMetadataBackendErr = server.NewMetadataBackend(config.MetadataBackendConfig, config.NewLogger()) }) + if initMetadataBackendErr != nil { + fmt.Printf("unable to initialize metadata backend : %s\n", initMetadataBackendErr) + os.Exit(1) + } } -// Initailze data backend +// Initialize data backend var initializeDataBackendOnce sync.Once var dataBackend data.Backend +var initDataBackendErr error func initializeDataBackend() { - var err error initializeDataBackendOnce.Do(func() { - dataBackend, err = server.NewDataBackend(config.DataBackend, config.DataBackendConfig) - if err != nil { - fmt.Printf("unable to initialize data backend : %s\n", err) - os.Exit(1) - } + dataBackend, initDataBackendErr = server.NewDataBackend(config.DataBackend, config.DataBackendConfig) }) + if initDataBackendErr != nil { + fmt.Printf("unable to initialize data backend : %s\n", initDataBackendErr) + os.Exit(1) + } } func startPlikServer(cmd *cobra.Command, args []string) { @@ -144,13 +144,14 @@ func startPlikServer(cmd *cobra.Command, args []string) { os.Exit(1) } + // Wait for shutdown signal c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) - go func() { - <-c - _ = plik.Shutdown(time.Minute) - os.Exit(0) - }() + <-c - select {} + err = plik.Shutdown(time.Minute) + if err != nil { + fmt.Printf("error during shutdown : %s\n", err) + os.Exit(1) + } } diff --git a/server/cmd/token.go b/server/cmd/token.go index a4f64469f..902539c7f 100644 --- a/server/cmd/token.go +++ b/server/cmd/token.go @@ -88,7 +88,7 @@ func createToken(cmd *cobra.Command, args []string) { } if user == nil { - fmt.Printf("User %s does not found\n", userID) + fmt.Printf("User %s not found\n", userID) os.Exit(1) } @@ -133,7 +133,7 @@ func listTokens(cmd *cobra.Command, args []string) { err := metadataBackend.ForEachToken(f) if err != nil { - fmt.Printf("Unable to get users : %s\n", err) + fmt.Printf("Unable to list tokens : %s\n", err) os.Exit(1) } } @@ -153,7 +153,7 @@ func deleteToken(cmd *cobra.Command, args []string) { deleted, err := metadataBackend.DeleteToken(tokenParams.token) if err != nil { - fmt.Printf("Unable to delete user : %s\n", err) + fmt.Printf("Unable to delete token : %s\n", err) os.Exit(1) } diff --git a/server/cmd/user.go b/server/cmd/user.go index c305a4c3e..9c0c0e124 100644 --- a/server/cmd/user.go +++ b/server/cmd/user.go @@ -2,10 +2,11 @@ package cmd import ( "fmt" + "os" + "github.com/dustin/go-humanize" "github.com/root-gg/utils" "github.com/spf13/cobra" - "os" "github.com/root-gg/plik/server/common" "github.com/root-gg/plik/server/server" @@ -35,35 +36,45 @@ var userCmd = &cobra.Command{ var createUserCmd = &cobra.Command{ Use: "create", Short: "Create user", - Run: createUser, + Example: ` plikd user create --login admin --password s3cret123 --admin + plikd user create --provider google --login user@gmail.com --name "John Doe" + plikd user create --login bob --max-file-size 100MB --max-ttl 7d`, + Run: createUser, } // listUsersCmd represents the "user list" command var listUsersCmd = &cobra.Command{ - Use: "list", - Short: "List users", - Run: listUsers, + Use: "list", + Short: "List users", + Example: ` plikd user list`, + Run: listUsers, } // showUserCmd represents the "user show" command var showUserCmd = &cobra.Command{ Use: "show", Short: "Show user info", - Run: showUser, + Example: ` plikd user show --login admin + plikd user show --provider google --login user@gmail.com`, + Run: showUser, } // updateUserCmd represents the "user update" command var updateUserCmd = &cobra.Command{ Use: "update", Short: "Update user info", - Run: updateUser, + Example: ` plikd user update --login admin --admin + plikd user update --login bob --max-file-size 500MB --max-ttl 14d`, + Run: updateUser, } // deleteUserCmd represents the "user delete" command var deleteUserCmd = &cobra.Command{ Use: "delete", Short: "Delete user", - Run: deleteUser, + Example: ` plikd user delete --login admin + plikd user delete --provider google --login user@gmail.com`, + Run: deleteUser, } func init() { @@ -96,6 +107,36 @@ func init() { userCmd.AddCommand(deleteUserCmd) } +// parseMaxSize parses a human-readable byte size flag value. +// Returns -1 for unlimited, 0 for unset, or the parsed byte count. +func parseMaxSize(flagName string, s string) int64 { + if s == "-1" { + return -1 + } + if s != "" { + v, err := humanize.ParseBytes(s) + if err != nil { + fmt.Printf("Unable to parse %s\n", flagName) + os.Exit(1) + } + return int64(v) + } + return 0 +} + +// parseMaxTTL parses the --max-ttl flag value. +func parseMaxTTL(s string) int { + if s != "" { + v, err := common.ParseTTL(s) + if err != nil { + fmt.Printf("Unable to parse max-ttl : %s\n", err) + os.Exit(1) + } + return v + } + return 0 +} + func createUser(cmd *cobra.Command, args []string) { if config.FeatureAuthentication == common.FeatureDisabled { fmt.Println("Authentication is disabled !") @@ -128,42 +169,14 @@ func createUser(cmd *cobra.Command, args []string) { // Create user params := &common.User{ - Provider: userParams.provider, - Login: userParams.login, - Name: userParams.name, - Email: userParams.email, - IsAdmin: userParams.admin, - } - - if userParams.maxFileSize == "-1" { - params.MaxFileSize = -1 - } else if userParams.maxFileSize != "" { - maxFileSize, err := humanize.ParseBytes(userParams.maxFileSize) - if err != nil { - fmt.Printf("Unable to parse max-file-size\n") - os.Exit(1) - } - params.MaxFileSize = int64(maxFileSize) - } - - if userParams.maxUserSize == "-1" { - params.MaxUserSize = -1 - } else if userParams.maxUserSize != "" { - maxUserSize, err := humanize.ParseBytes(userParams.maxUserSize) - if err != nil { - fmt.Printf("Unable to parse max-user-size\n") - os.Exit(1) - } - params.MaxUserSize = int64(maxUserSize) - } - - if userParams.maxTTL != "" { - maxTTL, err := common.ParseTTL(userParams.maxTTL) - if err != nil { - fmt.Printf("Unable to parse max-ttl\n") - os.Exit(1) - } - params.MaxTTL = maxTTL + Provider: userParams.provider, + Login: userParams.login, + Name: userParams.name, + Email: userParams.email, + IsAdmin: userParams.admin, + MaxFileSize: parseMaxSize("max-file-size", userParams.maxFileSize), + MaxUserSize: parseMaxSize("max-user-size", userParams.maxUserSize), + MaxTTL: parseMaxTTL(userParams.maxTTL), } if userParams.provider == common.ProviderLocal { @@ -249,13 +262,13 @@ func updateUser(cmd *cobra.Command, args []string) { } params := &common.User{} - if userParams.name != "" { + if cmd.Flags().Changed("name") { params.Name = userParams.name } else { params.Name = user.Name } - if userParams.email != "" { + if cmd.Flags().Changed("email") { params.Email = userParams.email } else { params.Email = user.Email @@ -267,39 +280,20 @@ func updateUser(cmd *cobra.Command, args []string) { params.IsAdmin = user.IsAdmin } - if userParams.maxFileSize == "-1" { - params.MaxFileSize = -1 - } else if userParams.maxFileSize != "" { - maxFileSize, err := humanize.ParseBytes(userParams.maxFileSize) - if err != nil { - fmt.Printf("Unable to parse max-file-size\n") - os.Exit(1) - } - params.MaxFileSize = int64(maxFileSize) + if cmd.Flags().Changed("max-file-size") { + params.MaxFileSize = parseMaxSize("max-file-size", userParams.maxFileSize) } else { params.MaxFileSize = user.MaxFileSize } - if userParams.maxUserSize == "-1" { - params.MaxUserSize = -1 - } else if userParams.maxUserSize != "" { - maxUserSize, err := humanize.ParseBytes(userParams.maxUserSize) - if err != nil { - fmt.Printf("Unable to parse max-user-size\n") - os.Exit(1) - } - params.MaxUserSize = int64(maxUserSize) + if cmd.Flags().Changed("max-user-size") { + params.MaxUserSize = parseMaxSize("max-user-size", userParams.maxUserSize) } else { params.MaxUserSize = user.MaxUserSize } - if userParams.maxTTL != "" { - maxTTL, err := common.ParseTTL(userParams.maxTTL) - if err != nil { - fmt.Printf("Unable to parse max-ttl : %s\n", err) - os.Exit(1) - } - params.MaxTTL = maxTTL + if cmd.Flags().Changed("max-ttl") { + params.MaxTTL = parseMaxTTL(userParams.maxTTL) } else { params.MaxTTL = user.MaxTTL } @@ -365,6 +359,17 @@ func deleteUser(cmd *cobra.Command, args []string) { userID := common.GetUserID(userParams.provider, userParams.login) + // Verify the user exists before prompting + user, err := metadataBackend.GetUser(userID) + if err != nil { + fmt.Printf("Unable to get user : %s\n", err) + os.Exit(1) + } + if user == nil { + fmt.Printf("user %s not found\n", userID) + os.Exit(1) + } + // Ask confirmation fmt.Printf("Do you really want to delete this user %s and all its uploads ? [y/N]\n", userID) ok, err := common.AskConfirmation(false) @@ -376,21 +381,7 @@ func deleteUser(cmd *cobra.Command, args []string) { os.Exit(0) } - deleted, err := metadataBackend.DeleteUser(userID) - if err != nil { - fmt.Printf("Unable to delete user : %s\n", err) - os.Exit(1) - } - - if !deleted { - fmt.Printf("user %s not found\n", userID) - os.Exit(1) - } - - fmt.Printf("user %s has been deleted\n", userID) - - // Delete user uploads - + // 1. Soft-delete user uploads first (marks files for removal) deleteUpload := func(upload *common.Upload) error { return metadataBackend.RemoveUpload(upload.ID) } @@ -400,14 +391,26 @@ func deleteUser(cmd *cobra.Command, args []string) { os.Exit(1) } - // Delete files - + // 2. Clean data backend (delete actual files from storage) plik := server.NewPlikServer(config) plik.WithMetadataBackend(metadataBackend) initializeDataBackend() plik.WithDataBackend(dataBackend) - // Delete upload and files plik.Clean() + + // 3. Delete user from metadata backend + deleted, err := metadataBackend.DeleteUser(userID) + if err != nil { + fmt.Printf("Unable to delete user : %s\n", err) + os.Exit(1) + } + + if !deleted { + fmt.Printf("user %s not found\n", userID) + os.Exit(1) + } + + fmt.Printf("user %s has been deleted\n", userID) }