Add auto complete for ZSH and Bash

This commit is contained in:
Micaiah Martin
2026-06-04 11:51:01 -06:00
parent 8909baca8b
commit 3c7606fdc6
15 changed files with 125 additions and 28 deletions

View File

@@ -14,8 +14,7 @@ public static class ApplyCommand
var manifest = new Option<string>("--manifest", "-m")
{ Description = "Path to the YAML manifest.", DefaultValueFactory = _ => "bitwarden.yaml" };
var deployment = new Option<string?>("--deployment", "-d")
{ Description = "standard | lite (defaults to the manifest)." };
var deployment = Cli.DeploymentOption("standard | lite (defaults to the manifest).");
var root = new Option<string>("--root")
{ Description = "Data directory (bwdata).", DefaultValueFactory = _ => "./bwdata" };
@@ -38,10 +37,9 @@ public static class ApplyCommand
var rootDir = parseResult.GetValue(root)!;
var ctx = new InstallContext { Root = rootDir, Manifest = loaded };
// Re-render config/env from the manifest WITHOUT regenerating secrets (generation reuses
// on-disk secrets), then recreate the stack so the new env takes effect. UpAsync force-
// recreates every container, so the whole stack restarts briefly; data survives via the
// named volume. Selective (only-changed) recreate is a future refinement.
// Re-render from the manifest (secrets are reused, not regenerated), then recreate the
// stack to pick up the changes. UpAsync recreates every container, so the stack restarts
// briefly; data survives via the named volume.
await dep.GenerateAssetsAsync(ctx, ct);
var topology = dep.BuildTopology(ctx);

View File

@@ -15,7 +15,7 @@ public static class BackupCommand
{
var cmd = new Command("backup", "Back up a deployment (config, secrets, certs, attachments + a DB dump) to a .tar.gz.");
var deployment = new Option<string?>("--deployment", "-d") { Description = "standard | lite." };
var deployment = Cli.DeploymentOption();
var root = new Option<string>("--root")
{ Description = "Data directory (bwdata).", DefaultValueFactory = _ => "./bwdata" };
var output = new Option<string?>("--out", "-o")

View File

@@ -1,4 +1,5 @@
using Bit.SelfHost.Deployments;
using System.CommandLine;
using Bit.SelfHost.Deployments;
using Bit.SelfHost.Engine;
using Spectre.Console;
@@ -7,6 +8,21 @@ namespace Bit.SelfHost.Commands;
/// <summary>Shared helpers for the command layer.</summary>
public static class Cli
{
/// <summary>Service names across both deployments, for `logs &lt;service&gt;` tab completion.</summary>
public static readonly string[] ServiceNames =
[
"mssql", "web", "attachments", "api", "identity", "sso", "admin", "icons",
"notifications", "events", "nginx", "key-connector", "scim",
];
/// <summary>The shared `--deployment` option, with standard|lite validation + tab completion.</summary>
public static Option<string?> DeploymentOption(string description = "standard | lite.")
{
var option = new Option<string?>("--deployment", "-d") { Description = description };
option.AcceptOnlyFromAmong("standard", "lite");
return option;
}
/// <summary>The image tag of a running container (e.g. "2026.4.1"), or null if it isn't present.</summary>
public static async Task<string?> ImageTagAsync(IContainerEngine engine, string container, CancellationToken ct)
{

View File

@@ -0,0 +1,69 @@
using System.CommandLine;
namespace Bit.SelfHost.Commands;
public static class CompletionsCommand
{
public static Command Build()
{
var cmd = new Command("completions", "Print a shell completion script (zsh or bash).");
var shell = new Argument<string>("shell") { Description = "Shell to generate completions for (zsh, bash)." };
shell.AcceptOnlyFromAmong("zsh", "bash");
cmd.Arguments.Add(shell);
cmd.SetAction(parseResult =>
{
Console.WriteLine(parseResult.GetValue(shell) == "bash" ? Bash : Zsh);
return 0;
});
return cmd;
}
// Both scripts delegate back to bwsh's own `[suggest]` directive for suggestions, so they stay
// correct as commands change and need no dotnet-suggest global tool.
private const string Zsh = """
#compdef bwsh
# zsh completion for bwsh.
#
# Load in the current shell:
# source <(bwsh completions zsh)
# Or install persistently:
# bwsh completions zsh > "${fpath[1]}/_bwsh" # then restart zsh
_bwsh() {
local -a items
# bwsh answers completion requests itself via the [suggest] directive.
items=( ${(f)"$(command bwsh "[suggest:${CURSOR}]" "$BUFFER" 2>/dev/null)"} )
if (( ${#items} )); then
compadd -- $items
else
_files # fall back to paths (e.g. --manifest, restore <archive>, --root)
fi
}
compdef _bwsh bwsh
""";
private const string Bash = """
# bash completion for bwsh.
#
# Load in the current shell:
# source <(bwsh completions bash)
# Or install persistently (with bash-completion installed):
# bwsh completions bash > /usr/local/etc/bash_completion.d/bwsh
_bwsh() {
local IFS=$'\n'
local cur="${COMP_WORDS[COMP_CWORD]}"
# bwsh answers completion requests itself via the [suggest] directive.
local items
items="$(command bwsh "[suggest:${COMP_POINT}]" "${COMP_LINE}" 2>/dev/null)"
COMPREPLY=( $(compgen -W "${items}" -- "${cur}") )
}
# -o default falls back to filenames when there are no matches (e.g. --manifest, restore).
complete -o default -F _bwsh bwsh
""";
}

View File

@@ -11,7 +11,7 @@ public static class ConfigCommand
var assignment = new Argument<string?>("assignment")
{ Description = "key=value to set, or key to get. Omit with --show to list.", Arity = ArgumentArity.ZeroOrOne };
var deployment = new Option<string?>("--deployment", "-d") { Description = "standard | lite." };
var deployment = Cli.DeploymentOption();
var root = new Option<string>("--root")
{ Description = "Data directory (bwdata).", DefaultValueFactory = _ => "./bwdata" };
var show = new Option<bool>("--show") { Description = "Show resolved config files." };
@@ -32,9 +32,9 @@ public static class ConfigCommand
{
Console.WriteLine($"{kind} deployment config files (under {rootDir}):");
Console.WriteLine(kind == DeploymentKind.Standard
? " config.yml (structural — `rebuild` to apply)\n" +
" env/global.override.env (SMTP/admin/globalSettings — `restart` to apply)"
: " settings.env (all BW_*/globalSettings__* — `restart` to apply)");
? " config.yml (structural; run `bwsh apply`)\n" +
" env/global.override.env (SMTP / admin / globalSettings; run `bwsh apply`)"
: " settings.env (all BW_* / globalSettings__*; run `bwsh apply`)");
return 0;
}
@@ -60,8 +60,8 @@ public static class ConfigCommand
{
Cli.UpsertEnv(target, key, value);
Console.WriteLine($"Set {key} in {binding.File}. Run `bwsh apply` to apply.");
// The manifest is the source of truth: `apply` re-renders from it, so add durable
// keys to the manifest's `config:` block (a bare `config set` is a one-off tweak).
// apply re-renders from the manifest, so add durable keys to its `config:` block;
// a bare `bwsh config key=value` is a one-off tweak.
Console.WriteLine("Add it to your manifest's `config:` block to persist across applies.");
}
else

View File

@@ -11,8 +11,7 @@ public static class InstallCommand
{
var cmd = new Command("install", "Install a Bitwarden self-host deployment.");
var deployment = new Option<string?>("--deployment", "-d")
{ Description = "Deployment type: standard | lite. Defaults to the manifest, else standard." };
var deployment = Cli.DeploymentOption("Deployment type: standard | lite. Defaults to the manifest, else standard.");
var manifest = new Option<string?>("--manifest", "-m")
{ Description = "Path to a YAML install manifest for an unattended install." };
var root = new Option<string>("--root")

View File

@@ -18,7 +18,8 @@ public static class LogsCommand
Description = "Service name (e.g. identity). Omit for the whole container.",
Arity = ArgumentArity.ZeroOrOne,
};
var deployment = new Option<string?>("--deployment", "-d") { Description = "standard | lite." };
service.CompletionSources.Add(Cli.ServiceNames);
var deployment = Cli.DeploymentOption();
var root = new Option<string>("--root")
{ Description = "Data directory (bwdata).", DefaultValueFactory = _ => "./bwdata" };
var tail = new Option<int>("--tail")

View File

@@ -12,7 +12,7 @@ public static class MigrateCommand
{
var cmd = new Command("migrate", "Adopt an existing bash/compose install under CLI management (non-destructive).");
var deployment = new Option<string?>("--deployment", "-d") { Description = "standard | lite." };
var deployment = Cli.DeploymentOption();
var root = new Option<string>("--root")
{ Description = "Data directory (bwdata).", DefaultValueFactory = _ => "./bwdata" };
var yes = new Option<bool>("--yes", "-y") { Description = "Skip confirmation." };

View File

@@ -13,7 +13,7 @@ public static class RestoreCommand
var cmd = new Command("restore", "Restore a deployment from a backup .tar.gz.");
var archive = new Argument<string>("archive") { Description = "Path to a backup .tar.gz." };
var deployment = new Option<string?>("--deployment", "-d") { Description = "standard | lite." };
var deployment = Cli.DeploymentOption();
var root = new Option<string>("--root")
{ Description = "Target data directory (bwdata).", DefaultValueFactory = _ => "./bwdata" };
var yes = new Option<bool>("--yes", "-y") { Description = "Skip confirmation." };

View File

@@ -11,7 +11,7 @@ public static class StatusCommand
{
var cmd = new Command("status", "Show the running state of a deployment's services.");
var deployment = new Option<string?>("--deployment", "-d") { Description = "standard | lite." };
var deployment = Cli.DeploymentOption();
var root = new Option<string>("--root")
{ Description = "Data directory (bwdata).", DefaultValueFactory = _ => "./bwdata" };

View File

@@ -10,7 +10,7 @@ public static class UninstallCommand
{
var cmd = new Command("uninstall", "Stop and remove the deployment's containers.");
var deployment = new Option<string?>("--deployment", "-d") { Description = "standard | lite." };
var deployment = Cli.DeploymentOption();
var root = new Option<string>("--root")
{ Description = "Data directory (bwdata).", DefaultValueFactory = _ => "./bwdata" };
var purge = new Option<bool>("--purge")

View File

@@ -10,7 +10,7 @@ public static class UpdateCommand
{
var cmd = new Command("update", "Pull target versions and recreate changed containers.");
var deployment = new Option<string?>("--deployment", "-d") { Description = "standard | lite." };
var deployment = Cli.DeploymentOption();
var root = new Option<string>("--root")
{ Description = "Data directory (bwdata).", DefaultValueFactory = _ => "./bwdata" };
var rebuild = new Option<bool>("--rebuild")

View File

@@ -12,6 +12,7 @@ root.Subcommands.Add(LogsCommand.Build());
root.Subcommands.Add(MigrateCommand.Build());
root.Subcommands.Add(BackupCommand.Build());
root.Subcommands.Add(RestoreCommand.Build());
root.Subcommands.Add(CompletionsCommand.Build());
// ProcessTerminationTimeout (default ~2s) makes System.CommandLine cancel the action's token on
// Ctrl+C/SIGTERM; we catch the resulting cancellation below and exit cleanly.

View File

@@ -75,6 +75,21 @@ dotnet run -- uninstall # stop and remove; --purge also deletes dat
Run `dotnet run -- <command> --help` for options on any command.
## Shell completion
`bwsh` prints a completion script (zsh or bash) that tab-completes commands, options, deployment
kinds (`standard`/`lite`), and service names. Load it with the `bwsh` binary on your PATH:
```bash
source <(bwsh completions zsh) # zsh, current shell
bwsh completions zsh > "${fpath[1]}/_bwsh" # zsh, persistent (restart zsh)
source <(bwsh completions bash) # bash, current shell
bwsh completions bash > /usr/local/etc/bash_completion.d/bwsh # bash, persistent
```
It delegates back to `bwsh` for suggestions, so no `dotnet-suggest` tool is needed.
## Migrate an existing bash install
Adopt a stack that was installed with `bitwarden.sh` under CLI management — non-destructive,

View File

@@ -12,9 +12,8 @@ public static class StandardAssetBuilder
IReadOnlyDictionary<string, string>? Config = null);
/// <summary>
/// Secrets read back from an existing bwdata/ so re-rendering (`apply`, or a re-run `install`)
/// reuses them instead of minting new ones — rotating the DB/identity passwords would mismatch
/// the already-initialized mssql volume and break the install.
/// Secrets read back from an existing bwdata/ so re-rendering (apply, or a re-run install) reuses
/// them. Rotating the DB or identity password would break the already-initialized mssql volume.
/// </summary>
private sealed record ExistingSecrets(
string? IdentityCertPassword, string? DbPassword,
@@ -34,7 +33,7 @@ public static class StandardAssetBuilder
var existing = ReadExistingSecrets(root);
// Order matches Setup's Install(): cert first (yields the password the env file needs).
// Reuse the identity cert + its password when already present so `apply` doesn't rotate it.
// Reuse the identity cert and password if present so apply doesn't rotate it.
var identityCertPath = Path.Combine(root, "identity", "identity.pfx");
var identityCertPassword = existing.IdentityCertPassword ?? SecureRandom.String(32);
if (existing.IdentityCertPassword is null || !File.Exists(identityCertPath))
@@ -131,8 +130,7 @@ public static class StandardAssetBuilder
if (!config.PushNotifications)
globalOverride["globalSettings__pushRelayBaseUri"] = "REPLACE";
// Manifest `config:` passthrough LAST so it overrides the defaults above (e.g. flips
// mail__smtp__host from REPLACE to the operator's value) — mirrors LiteDeployment.
// Manifest `config:` passthrough last so it overrides the defaults above (e.g. SMTP).
if (install.Config is not null)
foreach (var kv in install.Config)
globalOverride[kv.Key] = kv.Value;