From 928bb8997a2624caa5cceda63cb5de728afc7c5d Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 07:58:32 +0200 Subject: [PATCH 01/16] fix(api): skip the auth-failure retry for non-refreshable credentials TokenSource.Invalidate now reports whether the next Credentials call can produce fresh credentials. StaticTokenSource returns false, so an auth failure with plain credentials no longer doubles the failing request against the flood gate; SessionTokenSource returns true and keeps the refresh-and-retry behaviour. --- internal/api/client.go | 22 +++++++++++----- internal/api/client_test.go | 26 +++++++++++++++++-- internal/auth/source.go | 7 ++++- ...t.xml => get_databases_request_single.xml} | 0 ...get_databases_response_success_single.xml} | 0 ...> get_directoryprotection_request_all.xml} | 0 ...ectoryprotection_response_success_all.xml} | 0 ...ml => get_softwareinstall_request_all.xml} | 0 ..._softwareinstall_response_success_all.xml} | 0 9 files changed, 45 insertions(+), 10 deletions(-) rename testdata/database/{get_database_request.xml => get_databases_request_single.xml} (100%) rename testdata/database/{get_database_response_success.xml => get_databases_response_success_single.xml} (100%) rename testdata/directoryprotection/{get_directoryprotections_request.xml => get_directoryprotection_request_all.xml} (100%) rename testdata/directoryprotection/{get_directoryprotections_response_success.xml => get_directoryprotection_response_success_all.xml} (100%) rename testdata/softwareinstall/{get_softwareinstalls_request.xml => get_softwareinstall_request_all.xml} (100%) rename testdata/softwareinstall/{get_softwareinstalls_response_success.xml => get_softwareinstall_response_success_all.xml} (100%) diff --git a/internal/api/client.go b/internal/api/client.go index ce97e48..bb6896b 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -25,10 +25,14 @@ const floodFallback = 2 * time.Second // Plain auth returns the password as AuthData; session auth returns a // short-lived 40-char token. After an authentication failure, Client // calls Invalidate so the next Credentials call may obtain a fresh -// token (e.g. by re-running the KasAuth flow). +// token (e.g. by re-running the KasAuth flow). Invalidate reports +// whether that next call can actually produce fresh credentials: +// a session source discards its cached token and returns true, while a +// static credential cannot refresh and returns false — Client then +// skips the pointless retry with identical credentials. type TokenSource interface { Credentials(ctx context.Context) (login, authData string, authType soap.AuthType, err error) - Invalidate() + Invalidate() bool } // Heartbeater is an optional TokenSource extension. After every @@ -60,8 +64,9 @@ func (s *StaticTokenSource) Credentials(_ context.Context) (string, string, soap } // Invalidate is a no-op for the static source. A static credential -// cannot refresh itself; an auth failure is therefore terminal. -func (s *StaticTokenSource) Invalidate() {} +// cannot refresh itself; an auth failure is therefore terminal and +// the returned false suppresses the Client's auth-failure retry. +func (s *StaticTokenSource) Invalidate() bool { return false } // Client posts KasApi calls through the transport, refreshes session // tokens on auth failures, and feeds the server-reported KasFloodDelay @@ -118,9 +123,12 @@ func (c *Client) Call(ctx context.Context, action string, params map[string]any) c.logger().Info("api: call", "action", action) resp, err := c.callOnce(ctx, action, params) if err != nil && IsAuthFailure(err) { - c.logger().Info("api: auth failure, refreshing token and retrying", "action", action) - c.Tokens.Invalidate() - resp, err = c.callOnce(ctx, action, params) + if c.Tokens.Invalidate() { + c.logger().Info("api: auth failure, refreshing token and retrying", "action", action) + resp, err = c.callOnce(ctx, action, params) + } else { + c.logger().Info("api: auth failure with non-refreshable credentials, not retrying", "action", action) + } } if err == nil { if hb, ok := c.Tokens.(Heartbeater); ok { diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 0356ade..a62d4c9 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -208,6 +208,27 @@ func TestCallRetriesOnSessionInvalid(t *testing.T) { } } +// A static credential cannot refresh itself, so an auth failure with a +// StaticTokenSource is terminal: retrying with identical credentials +// would only double the failing request against the flood gate. +func TestCallNoRetryOnAuthFailureWithStaticTokens(t *testing.T) { + body := loadFixture(t, "response_failed_no_auth.xml") + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + _, _ = w.Write(body) + })) + defer srv.Close() + + c := newAPIClient(srv, staticTokens()) + if _, err := c.Call(context.Background(), "get_accounts", nil); err == nil { + t.Fatal("expected auth-failure error") + } + if calls.Load() != 1 { + t.Errorf("server calls = %d, want 1 (no retry with non-refreshable credentials)", calls.Load()) + } +} + func TestCallNoRetryOnNonAuthFault(t *testing.T) { body := loadFixture(t, "account/add_account_response_failed_max_account_reached.xml") var calls atomic.Int32 @@ -285,9 +306,10 @@ func (c *countingTokens) Credentials(_ context.Context) (string, string, soap.Au return c.login, c.data, c.typ, nil } -func (c *countingTokens) Invalidate() { +func (c *countingTokens) Invalidate() bool { c.invalidations++ c.data = c.refresh + return true } // beatingTokens implements TokenSource + Heartbeater to verify the @@ -302,5 +324,5 @@ type beatingTokens struct { func (b *beatingTokens) Credentials(_ context.Context) (string, string, soap.AuthType, error) { return b.login, b.data, b.typ, nil } -func (b *beatingTokens) Invalidate() {} +func (b *beatingTokens) Invalidate() bool { return true } func (b *beatingTokens) Heartbeat(_ context.Context) { b.heartbeats++ } diff --git a/internal/auth/source.go b/internal/auth/source.go index 0baf9ca..f7f09ed 100644 --- a/internal/auth/source.go +++ b/internal/auth/source.go @@ -152,7 +152,11 @@ func (s *SessionTokenSource) Credentials(ctx context.Context) (string, string, s // context.Background so a successful invalidation cannot be lost just // because the user pressed Ctrl-C between the API failure and the // cleanup. The in-memory clear happens unconditionally either way. -func (s *SessionTokenSource) Invalidate() { +// +// Invalidate always returns true: a session source can re-run the +// KasAuth flow on the next Credentials call, so an auth-failure retry +// with fresh credentials is worthwhile. +func (s *SessionTokenSource) Invalidate() bool { s.mu.Lock() defer s.mu.Unlock() s.token = "" @@ -167,6 +171,7 @@ func (s *SessionTokenSource) Invalidate() { s.logger().Warn("auth: session store delete failed; in-memory cache cleared", "err", err) } } + return true } // Heartbeat extends the cached expiry by Lifetime when UpdateLifetime diff --git a/testdata/database/get_database_request.xml b/testdata/database/get_databases_request_single.xml similarity index 100% rename from testdata/database/get_database_request.xml rename to testdata/database/get_databases_request_single.xml diff --git a/testdata/database/get_database_response_success.xml b/testdata/database/get_databases_response_success_single.xml similarity index 100% rename from testdata/database/get_database_response_success.xml rename to testdata/database/get_databases_response_success_single.xml diff --git a/testdata/directoryprotection/get_directoryprotections_request.xml b/testdata/directoryprotection/get_directoryprotection_request_all.xml similarity index 100% rename from testdata/directoryprotection/get_directoryprotections_request.xml rename to testdata/directoryprotection/get_directoryprotection_request_all.xml diff --git a/testdata/directoryprotection/get_directoryprotections_response_success.xml b/testdata/directoryprotection/get_directoryprotection_response_success_all.xml similarity index 100% rename from testdata/directoryprotection/get_directoryprotections_response_success.xml rename to testdata/directoryprotection/get_directoryprotection_response_success_all.xml diff --git a/testdata/softwareinstall/get_softwareinstalls_request.xml b/testdata/softwareinstall/get_softwareinstall_request_all.xml similarity index 100% rename from testdata/softwareinstall/get_softwareinstalls_request.xml rename to testdata/softwareinstall/get_softwareinstall_request_all.xml diff --git a/testdata/softwareinstall/get_softwareinstalls_response_success.xml b/testdata/softwareinstall/get_softwareinstall_response_success_all.xml similarity index 100% rename from testdata/softwareinstall/get_softwareinstalls_response_success.xml rename to testdata/softwareinstall/get_softwareinstall_response_success_all.xml From 16d717680345da3b2fc26ee26e44780376203a1f Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 07:58:32 +0200 Subject: [PATCH 02/16] fix(transport): treat context cancellation as non-retryable context.Canceled / DeadlineExceeded from the HTTP round trip is the caller's decision to stop, not a transient server condition; doOnce now returns it without the retryable marker instead of burning a backoff sleep before failing on the same ctx again. --- internal/transport/client.go | 6 ++++++ internal/transport/client_test.go | 11 ++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/transport/client.go b/internal/transport/client.go index a479d0f..841658d 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -167,6 +167,12 @@ func (c *Client) doOnce(ctx context.Context, endpoint string, body []byte) ([]by resp, err := c.HTTPClient.Do(req) if err != nil { + // A cancelled or timed-out context is the caller's decision to + // stop, not a transient server condition — retrying would only + // burn a backoff sleep before failing on the same ctx again. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, fmt.Errorf("transport: post %s: %w", endpoint, err) + } return nil, &retryableError{err: fmt.Errorf("transport: post %s: %w", endpoint, err)} } defer func() { _ = resp.Body.Close() }() diff --git a/internal/transport/client_test.go b/internal/transport/client_test.go index af78b6c..de14013 100644 --- a/internal/transport/client_test.go +++ b/internal/transport/client_test.go @@ -374,19 +374,28 @@ func TestRecordDelayExtendsWhenNewDelayIsLonger(t *testing.T) { } func TestDoRespectsContextDeadlineDuringRequest(t *testing.T) { + var hits atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) <-r.Context().Done() })) defer srv.Close() c := newClient(srv, newFakeClock()) - c.MaxRetries = 0 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() _, err := c.Do(ctx, srv.URL, nil) if err == nil { t.Fatal("expected error on cancelled context") } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("err = %v, want context.DeadlineExceeded preserved", err) + } + // The caller's cancellation is not a transient server condition: + // even with MaxRetries at its default, exactly one attempt runs. + if hits.Load() != 1 { + t.Errorf("requests = %d, want 1 (no retry on context cancellation)", hits.Load()) + } } // The 16-MB soap.MaxResponseBytes cap must bite at the transport read: From b91c854a92307c72d0576eb3d249aa2e850acb76 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 07:58:32 +0200 Subject: [PATCH 03/16] fix(cli): prompt on stderr, audit refusals, keep exit class on audit failure Three write-path fixes from the review: the destructive [y/N] prompt goes to stderr so a redirected stdout cannot swallow it; refused and declined destructive attempts leave an audit record (outcome=refused / declined); and a dispatched write whose audit sink fails keeps its true exit classification (KAS fault stays exit 2, a successful write renders its result before the audit error surfaces as exit 1). --- docs/usage/destructive-writes.md | 10 ++++- internal/cli/audit.go | 11 ++++++ internal/cli/directoryprotection_test.go | 4 ++ internal/cli/dryrun.go | 5 ++- internal/cli/dryrun_test.go | 7 +++- internal/cli/run.go | 49 ++++++++++++++++++++++-- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/docs/usage/destructive-writes.md b/docs/usage/destructive-writes.md index cfd89f2..9d866a7 100644 --- a/docs/usage/destructive-writes.md +++ b/docs/usage/destructive-writes.md @@ -100,7 +100,10 @@ ts=2026-05-16T12:00:00Z login=w0000001 action=delete_dns_settings target="record ``` `outcome` is `success`, `failure:` for a typed KAS fault, or a -bare `failure` for a transport/decode error. +bare `failure` for a transport/decode error. A destructive attempt that +never dispatched is also recorded: `declined` when the `[y/N]` prompt +was answered with no, `refused` when stdin was not a TTY and `--yes` +was not given. Passing `--audit-log ` (or setting `KAS_AUDIT_LOG`; the flag wins) additionally appends the same record as one JSON object per line @@ -108,7 +111,10 @@ additionally appends the same record as one JSON object per line Secret request parameters (`auth_data`, `*password`, `*token`, `*secret`, …) are replaced with `` in **both** sinks and never -written. Read commands produce no audit record. +written. Multi-line or oversized values (e.g. the `mail lists update` +`--config-file` / `--subscriber` blobs, which can contain the list +password) are elided to ``. Read commands produce no +audit record. ## `--dry-run`: preview without dispatching diff --git a/internal/cli/audit.go b/internal/cli/audit.go index 8460574..192744c 100644 --- a/internal/cli/audit.go +++ b/internal/cli/audit.go @@ -33,6 +33,17 @@ type AuditRecord struct { // from a real success or failure. const AuditOutcomeDryRun = "dry-run" +// AuditOutcomeDeclined and AuditOutcomeRefused are the Outcome values +// for destructive attempts that never dispatched: "declined" when the +// user answered the [y/N] prompt with no, "refused" when stdin was not +// a TTY and --yes was not given. Auditing the attempt keeps the trace +// complete — a blocked destructive action is still an action someone +// tried to run. +const ( + AuditOutcomeDeclined = "declined" + AuditOutcomeRefused = "refused" +) + // OutcomeFor maps a write call's error to the audit outcome string: // "success" on nil, "failure:" for a typed KAS fault, and a // bare "failure" for any other (transport/decode) error. diff --git a/internal/cli/directoryprotection_test.go b/internal/cli/directoryprotection_test.go index 01cb563..de594e4 100644 --- a/internal/cli/directoryprotection_test.go +++ b/internal/cli/directoryprotection_test.go @@ -166,6 +166,10 @@ func TestDirectoryProtectionDestructiveRefuseNonTTY(t *testing.T) { if !errors.Is(err, cli.ErrConfirmationRequired) { t.Errorf("err = %v, want ErrConfirmationRequired", err) } + // The blocked attempt still leaves an audit trace. + if !strings.Contains(buf.String(), "outcome=refused") { + t.Errorf("audit line with outcome=refused missing; output: %s", buf.String()) + } }) } } diff --git a/internal/cli/dryrun.go b/internal/cli/dryrun.go index 3fc94d4..26f6554 100644 --- a/internal/cli/dryrun.go +++ b/internal/cli/dryrun.go @@ -124,7 +124,10 @@ func ResolveDestructive( if handled, herr := previewAndAudit(opts, out, stderr, auditFile, login, kasAction, confirm, params); handled { return false, herr } - if gerr := GateDestructive(in, out, isTTY, opts != nil && opts.Yes, confirm); gerr != nil { + // The prompt goes to stderr, not out: stdout carries the command's + // machine-readable result, and a redirected `cmd > file` must not + // swallow the [y/N] question the user is expected to answer. + if gerr := GateDestructive(in, stderr, isTTY, opts != nil && opts.Yes, confirm); gerr != nil { return false, gerr } return true, nil diff --git a/internal/cli/dryrun_test.go b/internal/cli/dryrun_test.go index ac3371c..07c79dd 100644 --- a/internal/cli/dryrun_test.go +++ b/internal/cli/dryrun_test.go @@ -159,8 +159,11 @@ func TestResolveDestructiveDelegatesToGate(t *testing.T) { if proceed || !errors.Is(err, cli.ErrConfirmationDeclined) { t.Fatalf("proceed=%v err=%v, want false/ErrConfirmationDeclined", proceed, err) } - if !strings.Contains(out.String(), "[y/N]") { - t.Errorf("expected a prompt; out=%q", out.String()) + if !strings.Contains(stderr.String(), "[y/N]") { + t.Errorf("expected the prompt on stderr; stderr=%q", stderr.String()) + } + if strings.Contains(out.String(), "[y/N]") { + t.Errorf("prompt must not pollute stdout; out=%q", out.String()) } }) diff --git a/internal/cli/run.go b/internal/cli/run.go index 5665136..89faeae 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -2,6 +2,8 @@ package cli import ( "context" + "errors" + "fmt" "io" "time" @@ -135,6 +137,21 @@ func runWriteE(opts *RootOptions, build func(args []string) (writeSpec, error)) creds.Login, spec.action, spec.confirm, spec.params) if !proceed { // --dry-run → (false, nil): exit 0; declined/refused → (false, err). + // A gate refusal still leaves an audit trace: the attempt to run + // a destructive action is at least as interesting to an auditor + // as a dispatched one. An audit-write failure stays secondary — + // the refusal error is what the user must see. + if outcome := refusalOutcome(err); outcome != "" { + rec := AuditRecord{ + Time: time.Now().UTC(), + Login: creds.Login, + Action: spec.action, + Target: spec.confirm.ID, + Outcome: outcome, + Fields: RedactParams(spec.params), + } + _ = WriteAudit(stderr, auditFile, rec) + } return err } @@ -152,20 +169,44 @@ func runWriteE(opts *RootOptions, build func(args []string) (writeSpec, error)) Outcome: OutcomeFor(derr), Fields: RedactParams(spec.params), } - if werr := WriteAudit(stderr, auditFile, rec); werr != nil { - return UserError(werr, "audit") - } - + werr := WriteAudit(stderr, auditFile, rec) if derr != nil { + // The dispatch outcome outranks an audit-write failure: a KAS + // fault must keep its APIError classification (exit 2) even + // when the audit sink also broke. + if werr != nil { + _, _ = fmt.Fprintf(stderr, "warning: audit record not fully written: %v\n", werr) + } return APIError(derr, spec.action) } if rerr := Render(out, opts.Output, writeResult{Message: result}); rerr != nil { return UserError(rerr, "render") } + if werr != nil { + // The write itself succeeded (result already rendered above); + // a broken audit sink still exits non-zero because the trace + // contract could not be honoured. + return UserError(werr, "audit") + } return nil } } +// refusalOutcome maps a WriteResolver refusal error to its audit +// outcome: "declined" for an interactive no, "refused" for the +// non-TTY-without---yes abort. Any other error (or nil, the --dry-run +// case, which writes its own record) yields "" — no record. +func refusalOutcome(err error) string { + switch { + case errors.Is(err, ErrConfirmationDeclined): + return AuditOutcomeDeclined + case errors.Is(err, ErrConfirmationRequired): + return AuditOutcomeRefused + default: + return "" + } +} + // writeResult wraps the human success line a write dispatch returns so // it renders through the same --output pipeline the read commands use: // table output stays the bare line (no header row), json/yaml emit a From 97d517ec2855f2c4e3eadf8367049390e87e9dab Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 07:58:43 +0200 Subject: [PATCH 04/16] fix(auth): validate credential token shape and session lifetime range DecodeResponse rejects a value that is not 40 alphanumeric characters before it gets cached and persisted (the error reports only the length, never the content). EncodeRequest range-checks Lifetime against the documented 1..30000 session_lifetime bound (0 = server default). --- internal/auth/codec.go | 35 +++++++++++++++++++++++++++ internal/auth/codec_test.go | 47 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/internal/auth/codec.go b/internal/auth/codec.go index 489fbbc..b7a5c04 100644 --- a/internal/auth/codec.go +++ b/internal/auth/codec.go @@ -23,6 +23,16 @@ type Request struct { OTP string } +// maxSessionLifetime is the documented upper bound of the KasAuth +// session_lifetime parameter (seconds); the documented range is +// 1..30000, with an unset value (0 here) leaving the server default. +const maxSessionLifetime = 30000 + +// credentialTokenLength is the length of the credential token a +// successful KasAuth call returns (see doc.go): 40 alphanumeric +// characters. +const credentialTokenLength = 40 + const requestTemplate = ` @@ -43,6 +53,9 @@ func EncodeRequest(w io.Writer, r Request) error { if r.AuthData == "" { return errors.New("auth: Request.AuthData is required") } + if r.Lifetime < 0 || r.Lifetime > maxSessionLifetime { + return fmt.Errorf("auth: Request.Lifetime %d out of range 1..%d (0 = server default)", r.Lifetime, maxSessionLifetime) + } payload := map[string]any{ "kas_login": r.Login, "kas_auth_type": string(r.AuthType), @@ -126,6 +139,22 @@ func decodeBody(d *xml.Decoder, parent xml.StartElement) (string, error) { } } +// validToken reports whether s has the shape of a KasAuth credential +// token: exactly credentialTokenLength alphanumeric characters. +func validToken(s string) bool { + if len(s) != credentialTokenLength { + return false + } + for _, r := range s { + switch { + case r >= '0' && r <= '9', r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': + default: + return false + } + } + return true +} + func decodeKasAuthResponse(d *xml.Decoder, parent xml.StartElement) (string, error) { for { tok, err := d.Token() @@ -142,6 +171,12 @@ func decodeKasAuthResponse(d *xml.Decoder, parent xml.StartElement) (string, err if s == "" { return "", errors.New("auth: empty element") } + // Guard the token shape before it gets cached and + // persisted; only the length is reported so a partial + // secret can never leak into an error message. + if !validToken(s) { + return "", fmt.Errorf("auth: malformed credential token: got %d bytes, want %d alphanumeric characters", len(s), credentialTokenLength) + } return s, nil } if err := d.Skip(); err != nil { diff --git a/internal/auth/codec_test.go b/internal/auth/codec_test.go index 57cc0da..ee2c1f0 100644 --- a/internal/auth/codec_test.go +++ b/internal/auth/codec_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "os" "path/filepath" "strings" @@ -120,3 +121,49 @@ func TestDecodeResponseEmptyDocument(t *testing.T) { t.Fatal("expected error") } } + +// The credential token contract is 40 alphanumeric characters; anything +// else must be rejected before it gets cached and persisted. The error +// must not echo the token content — only its length. +func TestDecodeResponseRejectsMalformedToken(t *testing.T) { + const envelope = ` + + + + %s + + +` + for _, tok := range []string{ + "short", + strings.Repeat("a", 41), + strings.Repeat("a", 39) + "!", + } { + _, err := auth.DecodeResponse(strings.NewReader(fmt.Sprintf(envelope, tok))) + if err == nil { + t.Errorf("token %q: expected malformed-token error, got nil", tok) + continue + } + if strings.Contains(err.Error(), tok) { + t.Errorf("token content leaked into error: %v", err) + } + } +} + +// session_lifetime is documented as 1..30000 seconds; 0 means "leave +// the server default". Out-of-range values must fail at encode time so +// the local expiry mirror cannot silently diverge from the server. +func TestEncodeRequestRejectsOutOfRangeLifetime(t *testing.T) { + for _, lifetime := range []int{-1, 30001} { + var buf bytes.Buffer + err := auth.EncodeRequest(&buf, auth.Request{ + Login: "w0", + AuthType: soap.AuthPlain, + AuthData: "pw", + Lifetime: lifetime, + }) + if err == nil { + t.Errorf("Lifetime %d: expected range error, got nil", lifetime) + } + } +} From 40dc28dfb6dd06dff57a390f52f51aa17804d582 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 07:58:43 +0200 Subject: [PATCH 05/16] fix(config): add ErrUnknownProfile / ErrMissingCredentials sentinels Resolve failures were string-only errors; callers that need to branch now have errors.Is-able sentinels, per the stable-error-identifier rule. --- internal/config/config_test.go | 6 ++++++ internal/config/credentials.go | 15 +++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5da33e1..1470624 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -163,6 +163,9 @@ func TestResolveUnknownProfile(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "missing") { t.Fatalf("expected unknown-profile error, got %v", err) } + if !errors.Is(err, config.ErrUnknownProfile) { + t.Errorf("err = %v, want errors.Is ErrUnknownProfile", err) + } } func TestResolveMissingCredentials(t *testing.T) { @@ -176,6 +179,9 @@ func TestResolveMissingCredentials(t *testing.T) { if err == nil { t.Fatal("expected error for missing credentials") } + if !errors.Is(err, config.ErrMissingCredentials) { + t.Errorf("err = %v, want errors.Is ErrMissingCredentials", err) + } for _, want := range []string{"auth_data", "auth_type"} { if !strings.Contains(err.Error(), want) { t.Errorf("error %q does not mention %q", err, want) diff --git a/internal/config/credentials.go b/internal/config/credentials.go index 7f55f2e..35709ee 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -1,11 +1,22 @@ package config import ( + "errors" "fmt" "os" "strings" ) +// ErrUnknownProfile is returned by Resolve when the requested profile +// (via --profile or default_profile) does not exist in the config file. +// Callers branch with errors.Is instead of string-matching. +var ErrUnknownProfile = errors.New("config: profile not defined") + +// ErrMissingCredentials is returned by Resolve when the flag > env > +// config precedence still leaves login, auth_data, or auth_type empty. +// Callers branch with errors.Is instead of string-matching. +var ErrMissingCredentials = errors.New("config: missing credentials") + // Credentials are the resolved values for a single KAS API call. type Credentials struct { Login string @@ -61,7 +72,7 @@ func (c *Config) Resolve(env Env, ov Override) (Credentials, error) { if name != "" { p, ok := c.Profiles[name] if !ok { - return Credentials{}, fmt.Errorf("config: profile %q not defined", name) + return Credentials{}, fmt.Errorf("%w: %q", ErrUnknownProfile, name) } prof = p } @@ -98,7 +109,7 @@ func (c Credentials) validate() error { missing = append(missing, "auth_type (--auth-type or KAS_AUTHTYPE)") } if len(missing) > 0 { - return fmt.Errorf("config: missing credentials: %s", strings.Join(missing, ", ")) + return fmt.Errorf("%w: %s", ErrMissingCredentials, strings.Join(missing, ", ")) } if c.AuthType != AuthPlain && c.AuthType != AuthSession { return fmt.Errorf("config: auth_type %q must be %q or %q", c.AuthType, AuthPlain, AuthSession) From 2c59ac0e90244290a9ae2d4e63dec88a3f948b32 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 07:58:43 +0200 Subject: [PATCH 06/16] refactor: unexport the Caller field in dns/directoryprotection/server/usage Four modules exported their Caller as API while the other ten keep it unexported as c; unified on the unexported form (constructed via NewClient everywhere, no caller used the exported field). cronjob also gains a FieldID constant replacing three hardcoded cronjob_id literals. --- internal/cronjob/cronjob.go | 2 +- internal/cronjob/write.go | 8 ++++++-- .../directoryprotection/directoryprotection.go | 8 ++++---- internal/directoryprotection/write.go | 6 +++--- internal/dns/dns.go | 8 ++++---- internal/server/server.go | 8 ++++---- internal/usage/usage.go | 16 ++++++++-------- 7 files changed, 30 insertions(+), 26 deletions(-) diff --git a/internal/cronjob/cronjob.go b/internal/cronjob/cronjob.go index cb43b19..f2eb83b 100644 --- a/internal/cronjob/cronjob.go +++ b/internal/cronjob/cronjob.go @@ -98,7 +98,7 @@ func NewClient(c Caller) *Client { Action: "get_cronjobs", Label: "cronjob", ArgName: "id", - FilterKey: "cronjob_id", + FilterKey: FieldID, Decoder: DecodeCronjobs, }, c: c, diff --git a/internal/cronjob/write.go b/internal/cronjob/write.go index 8fc234a..6c1f006 100644 --- a/internal/cronjob/write.go +++ b/internal/cronjob/write.go @@ -19,6 +19,10 @@ const ( deleteAction = "delete_cronjob" ) +// FieldID is the cronjob_id identifier key shared by the get filter, +// update_cronjob, and delete_cronjob. +const FieldID = "cronjob_id" + // The Field-prefixed constants are the KAS request keys update_cronjob // accepts besides the cronjob_id identifier; AddParams uses the same // keys. Each is an optional wholesale replacement on update: only the @@ -148,7 +152,7 @@ func (cl *Client) Update(ctx context.Context, id string, fields map[string]strin // (single source of truth, see AddParams): the cronjob_id identifier // plus every caller-supplied mutable field verbatim. func UpdateParams(id string, fields map[string]string) map[string]any { - params := map[string]any{"cronjob_id": id} + params := map[string]any{FieldID: id} for k, v := range fields { params[k] = v } @@ -169,5 +173,5 @@ func (cl *Client) Delete(ctx context.Context, id string) error { // DeleteParams builds the delete_cronjob KAS request parameter map // (single source of truth, see AddParams). func DeleteParams(id string) map[string]any { - return map[string]any{"cronjob_id": id} + return map[string]any{FieldID: id} } diff --git a/internal/directoryprotection/directoryprotection.go b/internal/directoryprotection/directoryprotection.go index b337ba8..079dd9b 100644 --- a/internal/directoryprotection/directoryprotection.go +++ b/internal/directoryprotection/directoryprotection.go @@ -38,22 +38,22 @@ type DirectoryProtectionList []DirectoryProtection // even when no filter is set), so this module exposes one List // method that takes an optional path. type Client struct { - API Caller + c Caller } // NewClient returns a Client backed by the given Caller. -func NewClient(c Caller) *Client { return &Client{API: c} } +func NewClient(c Caller) *Client { return &Client{c: c} } // List calls get_directoryprotection and decodes the response into a // DirectoryProtectionList. An empty path returns every protected // directory; a non-empty path filters to that directory's user // entries (still a list, since multiple users per path are possible). -func (c *Client) List(ctx context.Context, path string) (DirectoryProtectionList, error) { +func (cl *Client) List(ctx context.Context, path string) (DirectoryProtectionList, error) { var params map[string]any if path != "" { params = map[string]any{"directory_path": path} } - resp, err := c.API.Call(ctx, "get_directoryprotection", params) + resp, err := cl.c.Call(ctx, "get_directoryprotection", params) if err != nil { return nil, err } diff --git a/internal/directoryprotection/write.go b/internal/directoryprotection/write.go index ffa0e79..c575902 100644 --- a/internal/directoryprotection/write.go +++ b/internal/directoryprotection/write.go @@ -74,7 +74,7 @@ func (cl *Client) Add(ctx context.Context, s Spec) (string, error) { case s.Password == "": return "", errors.New("directoryprotection: add_directoryprotection requires a non-empty directory password") } - resp, err := kaswrite.Call(ctx, cl.API, "directoryprotection", addAction, AddParams(s)) + resp, err := kaswrite.Call(ctx, cl.c, "directoryprotection", addAction, AddParams(s)) if err != nil { return "", err } @@ -111,7 +111,7 @@ func (cl *Client) Update(ctx context.Context, path, user string, fields map[stri if len(fields) == 0 { return errors.New("directoryprotection: update_directoryprotection requires at least one field to change") } - _, err := kaswrite.Call(ctx, cl.API, "directoryprotection", updateAction, UpdateParams(path, user, fields)) + _, err := kaswrite.Call(ctx, cl.c, "directoryprotection", updateAction, UpdateParams(path, user, fields)) return err } @@ -142,7 +142,7 @@ func (cl *Client) Delete(ctx context.Context, path, user string) error { case user == "": return errors.New("directoryprotection: delete_directoryprotection requires a non-empty directory user") } - _, err := kaswrite.Call(ctx, cl.API, "directoryprotection", deleteAction, DeleteParams(path, user)) + _, err := kaswrite.Call(ctx, cl.c, "directoryprotection", deleteAction, DeleteParams(path, user)) return err } diff --git a/internal/dns/dns.go b/internal/dns/dns.go index 243f576..e6808ac 100644 --- a/internal/dns/dns.go +++ b/internal/dns/dns.go @@ -35,18 +35,18 @@ type RecordList []Record // Client groups the read endpoints scoped to DNS settings. type Client struct { - API Caller + c Caller } // NewClient returns a Client backed by the given Caller. -func NewClient(c Caller) *Client { return &Client{API: c} } +func NewClient(c Caller) *Client { return &Client{c: c} } // Settings calls get_dns_settings for the given zone host and decodes // the response into a RecordList. zoneHost is required (the zone the // records belong to, e.g. "example.com"). recordID is optional and // narrows the result to the single resource record with that ID — // leave it empty to list every record in the zone. -func (c *Client) Settings(ctx context.Context, zoneHost, recordID string) (RecordList, error) { +func (cl *Client) Settings(ctx context.Context, zoneHost, recordID string) (RecordList, error) { if zoneHost == "" { return nil, fmt.Errorf("dns: zone_host is required") } @@ -54,7 +54,7 @@ func (c *Client) Settings(ctx context.Context, zoneHost, recordID string) (Recor if recordID != "" { params["record_id"] = recordID } - resp, err := c.API.Call(ctx, "get_dns_settings", params) + resp, err := cl.c.Call(ctx, "get_dns_settings", params) if err != nil { return nil, err } diff --git a/internal/server/server.go b/internal/server/server.go index 518248f..220b19d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -32,15 +32,15 @@ type ServiceList []Service // Client groups the read endpoints scoped to the host server. type Client struct { - API Caller + c Caller } // NewClient returns a Client backed by the given Caller. -func NewClient(c Caller) *Client { return &Client{API: c} } +func NewClient(c Caller) *Client { return &Client{c: c} } // Information calls get_server_information and decodes the response. -func (c *Client) Information(ctx context.Context) (ServiceList, error) { - resp, err := c.API.Call(ctx, "get_server_information", nil) +func (cl *Client) Information(ctx context.Context) (ServiceList, error) { + resp, err := cl.c.Call(ctx, "get_server_information", nil) if err != nil { return nil, err } diff --git a/internal/usage/usage.go b/internal/usage/usage.go index 98057d9..c4af89e 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -83,19 +83,19 @@ type TrafficList []Traffic // Client groups the read endpoints scoped to webspace + traffic // counters: get_space, get_space_usage, get_traffic. type Client struct { - API Caller + c Caller } // NewClient returns a Client backed by the given Caller. -func NewClient(c Caller) *Client { return &Client{API: c} } +func NewClient(c Caller) *Client { return &Client{c: c} } // Space calls get_space and decodes the response into SpaceList. // // The KAS API accepts optional show_subaccounts and show_details // parameters; both default to "Y" server-side. We omit them — callers // that need a different scope can call (*api.Client).Call directly. -func (c *Client) Space(ctx context.Context) (SpaceList, error) { - resp, err := c.API.Call(ctx, "get_space", nil) +func (cl *Client) Space(ctx context.Context) (SpaceList, error) { + resp, err := cl.c.Call(ctx, "get_space", nil) if err != nil { return nil, err } @@ -108,12 +108,12 @@ func (c *Client) Space(ctx context.Context) (SpaceList, error) { // SpaceUsage calls get_space_usage for the given directory and decodes // the response. An empty directory queries the document-root level. -func (c *Client) SpaceUsage(ctx context.Context, directory string) (SpaceUsageList, error) { +func (cl *Client) SpaceUsage(ctx context.Context, directory string) (SpaceUsageList, error) { var params map[string]any if directory != "" { params = map[string]any{"directory": directory} } - resp, err := c.API.Call(ctx, "get_space_usage", params) + resp, err := cl.c.Call(ctx, "get_space_usage", params) if err != nil { return nil, err } @@ -128,7 +128,7 @@ func (c *Client) SpaceUsage(ctx context.Context, directory string) (SpaceUsageLi // are optional; pass 0/0 to query the current month. month is encoded // as a zero-padded string ("01".."12") because KAS rejects "1" with a // syntax error. -func (c *Client) Traffic(ctx context.Context, year, month int) (TrafficList, error) { +func (cl *Client) Traffic(ctx context.Context, year, month int) (TrafficList, error) { var params map[string]any if year != 0 || month != 0 { params = make(map[string]any, 2) @@ -139,7 +139,7 @@ func (c *Client) Traffic(ctx context.Context, year, month int) (TrafficList, err params["month"] = fmt.Sprintf("%02d", month) } } - resp, err := c.API.Call(ctx, "get_traffic", params) + resp, err := cl.c.Call(ctx, "get_traffic", params) if err != nil { return nil, err } From 8faa9e72fc68cd1661e6a119aeab60054b9c7dc0 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 07:58:56 +0200 Subject: [PATCH 07/16] fix(testdata): rename fixtures encoding non-existent KAS actions get_database, get_directoryprotections, and get_softwareinstalls are not real KAS actions (the fixtures dispatch get_databases / get_directoryprotection / get_softwareinstall); the files now carry the real action name plus a variant suffix (_single for the filtered variant, _all for the unfiltered list), and the three doc.go endpoint lists no longer name the non-actions. --- internal/database/database_test.go | 6 +++--- internal/database/doc.go | 6 ++++-- internal/directoryprotection/directoryprotection_test.go | 6 +++--- internal/directoryprotection/doc.go | 8 ++++---- internal/softwareinstall/doc.go | 5 +++-- internal/softwareinstall/softwareinstall_test.go | 6 +++--- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 4248b4a..3e24d9e 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -48,7 +48,7 @@ func TestDecodeDatabases(t *testing.T) { func TestDecodeDatabaseSingular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "database/get_database_response_success.xml") + resp := testutil.DecodeFixture(t, "database/get_databases_response_success_single.xml") got, err := database.DecodeDatabases(resp.Body.ReturnInfo) if err != nil { t.Fatalf("DecodeDatabases: %v", err) @@ -86,7 +86,7 @@ func TestClientList(t *testing.T) { func TestClientGet(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "database/get_database_response_success.xml") + resp := testutil.DecodeFixture(t, "database/get_databases_response_success_single.xml") fc := &testutil.FakeCaller{Resp: resp} d, err := database.NewClient(fc).Get(context.Background(), "d0123460") if err != nil { @@ -151,7 +151,7 @@ func TestDatabaseListTabular(t *testing.T) { func TestDatabaseTabular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "database/get_database_response_success.xml") + resp := testutil.DecodeFixture(t, "database/get_databases_response_success_single.xml") list, _ := database.DecodeDatabases(resp.Body.ReturnInfo) if len(list) != 1 { t.Fatalf("len = %d, want 1", len(list)) diff --git a/internal/database/doc.go b/internal/database/doc.go index 5477d19..ba41ffd 100644 --- a/internal/database/doc.go +++ b/internal/database/doc.go @@ -1,4 +1,6 @@ // Package database holds the domain types and use cases for the KAS -// database endpoints (get_databases, get_database, add_database, -// update_database, delete_database). See issues #11 and #13. +// database endpoints (get_databases, add_database, update_database, +// delete_database — there is no singular get action; a single database is +// fetched via get_databases with the database_login filter). See issues +// #11 and #13. package database diff --git a/internal/directoryprotection/directoryprotection_test.go b/internal/directoryprotection/directoryprotection_test.go index 0ecddae..78bfbb7 100644 --- a/internal/directoryprotection/directoryprotection_test.go +++ b/internal/directoryprotection/directoryprotection_test.go @@ -11,7 +11,7 @@ import ( func TestDecodeDirectoryProtections(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "directoryprotection/get_directoryprotections_response_success.xml") + resp := testutil.DecodeFixture(t, "directoryprotection/get_directoryprotection_response_success_all.xml") got, err := directoryprotection.DecodeDirectoryProtections(resp.Body.ReturnInfo) if err != nil { t.Fatalf("DecodeDirectoryProtections: %v", err) @@ -57,7 +57,7 @@ func TestDecodeDirectoryProtectionSingular(t *testing.T) { func TestClientListNoFilter(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "directoryprotection/get_directoryprotections_response_success.xml") + resp := testutil.DecodeFixture(t, "directoryprotection/get_directoryprotection_response_success_all.xml") fc := &testutil.FakeCaller{Resp: resp} list, err := directoryprotection.NewClient(fc).List(context.Background(), "") if err != nil { @@ -107,7 +107,7 @@ func TestClientPropagatesError(t *testing.T) { func TestDirectoryProtectionListTabular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "directoryprotection/get_directoryprotections_response_success.xml") + resp := testutil.DecodeFixture(t, "directoryprotection/get_directoryprotection_response_success_all.xml") list, _ := directoryprotection.DecodeDirectoryProtections(resp.Body.ReturnInfo) headers := list.TableHeaders() if headers[0] != "PATH" { diff --git a/internal/directoryprotection/doc.go b/internal/directoryprotection/doc.go index ab8c403..563f192 100644 --- a/internal/directoryprotection/doc.go +++ b/internal/directoryprotection/doc.go @@ -1,6 +1,6 @@ // Package directoryprotection holds the domain types and use cases for the -// KAS directory-protection endpoints (get_directoryprotections, -// get_directoryprotection, add_directoryprotection, -// update_directoryprotection, delete_directoryprotection). See issues #11 -// and #13. +// KAS directory-protection endpoints (get_directoryprotection, +// add_directoryprotection, update_directoryprotection, +// delete_directoryprotection — there is no plural get action; the list is +// the unfiltered get_directoryprotection call). See issues #11 and #13. package directoryprotection diff --git a/internal/softwareinstall/doc.go b/internal/softwareinstall/doc.go index fa09160..2f45add 100644 --- a/internal/softwareinstall/doc.go +++ b/internal/softwareinstall/doc.go @@ -1,5 +1,6 @@ // Package softwareinstall holds the domain types and use cases for the KAS -// software-install endpoints (get_softwareinstalls, get_softwareinstall, -// add_softwareinstall). The KAS API has no update or delete for this +// software-install endpoints (get_softwareinstall, add_softwareinstall — +// there is no plural get action; the list is the unfiltered +// get_softwareinstall call). The KAS API has no update or delete for this // resource. See issues #11 and #13. package softwareinstall diff --git a/internal/softwareinstall/softwareinstall_test.go b/internal/softwareinstall/softwareinstall_test.go index 0e35e72..c4d488e 100644 --- a/internal/softwareinstall/softwareinstall_test.go +++ b/internal/softwareinstall/softwareinstall_test.go @@ -12,7 +12,7 @@ import ( func TestDecodeSoftwareInstalls(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "softwareinstall/get_softwareinstalls_response_success.xml") + resp := testutil.DecodeFixture(t, "softwareinstall/get_softwareinstall_response_success_all.xml") got, err := softwareinstall.DecodeSoftwareInstalls(resp.Body.ReturnInfo) if err != nil { t.Fatalf("DecodeSoftwareInstalls: %v", err) @@ -75,7 +75,7 @@ func TestDecodeSoftwareInstallSingular(t *testing.T) { func TestClientList(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "softwareinstall/get_softwareinstalls_response_success.xml") + resp := testutil.DecodeFixture(t, "softwareinstall/get_softwareinstall_response_success_all.xml") fc := &testutil.FakeCaller{Resp: resp} list, err := softwareinstall.NewClient(fc).List(context.Background()) if err != nil { @@ -142,7 +142,7 @@ func TestClientPropagatesError(t *testing.T) { func TestSoftwareInstallListTabular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "softwareinstall/get_softwareinstalls_response_success.xml") + resp := testutil.DecodeFixture(t, "softwareinstall/get_softwareinstall_response_success_all.xml") list, _ := softwareinstall.DecodeSoftwareInstalls(resp.Body.ReturnInfo) headers := list.TableHeaders() if headers[0] != "ID" { From 4fcaee24d1ee03d9dcd10598122a89c8efc8985a Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 07:58:56 +0200 Subject: [PATCH 08/16] test(chown,ssl,symlink): anchor the captured placeholder fault fixtures The #125 placeholder modules ship ~25 captured fault fixtures that no test referenced; a one-line AssertFaultFixtures test per package binds them to the KAS contract until the write slices land. --- internal/chown/fault_test.go | 18 ++++++++++++++++++ internal/ssl/fault_test.go | 18 ++++++++++++++++++ internal/symlink/fault_test.go | 19 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 internal/chown/fault_test.go create mode 100644 internal/ssl/fault_test.go create mode 100644 internal/symlink/fault_test.go diff --git a/internal/chown/fault_test.go b/internal/chown/fault_test.go new file mode 100644 index 0000000..c5e952d --- /dev/null +++ b/internal/chown/fault_test.go @@ -0,0 +1,18 @@ +package chown_test + +import ( + "testing" + + "github.com/chmmou/kasapi-cli/internal/testutil" +) + +// The chown write slice is not implemented yet (#125), but its captured +// fault fixtures already live in testdata/chown/. Anchoring them via the +// shared testutil.AssertFaultFixtures keeps them bound to the KAS +// contract instead of rotting unreferenced until the slice lands. +func TestFaultFixturesDecodeToDocumentedCodes(t *testing.T) { + t.Parallel() + testutil.AssertFaultFixtures(t, "chown", map[string]string{ + "update_chown_response_failed_missing_parameter.xml": "missing_parameter", + }) +} diff --git a/internal/ssl/fault_test.go b/internal/ssl/fault_test.go new file mode 100644 index 0000000..68cc952 --- /dev/null +++ b/internal/ssl/fault_test.go @@ -0,0 +1,18 @@ +package ssl_test + +import ( + "testing" + + "github.com/chmmou/kasapi-cli/internal/testutil" +) + +// The ssl write slice is not implemented yet (#125), but its captured +// fault fixtures already live in testdata/ssl/. Anchoring them via the +// shared testutil.AssertFaultFixtures keeps them bound to the KAS +// contract instead of rotting unreferenced until the slice lands. +func TestFaultFixturesDecodeToDocumentedCodes(t *testing.T) { + t.Parallel() + testutil.AssertFaultFixtures(t, "ssl", map[string]string{ + "update_ssl_response_failed_nothing_to_do.xml": "nothing_to_do", + }) +} diff --git a/internal/symlink/fault_test.go b/internal/symlink/fault_test.go new file mode 100644 index 0000000..4a87e54 --- /dev/null +++ b/internal/symlink/fault_test.go @@ -0,0 +1,19 @@ +package symlink_test + +import ( + "testing" + + "github.com/chmmou/kasapi-cli/internal/testutil" +) + +// The symlink write slice is not implemented yet (#125), but its +// captured fault fixtures already live in testdata/symlink/. Anchoring +// them via the shared testutil.AssertFaultFixtures keeps them bound to +// the KAS contract instead of rotting unreferenced until the slice +// lands. +func TestFaultFixturesDecodeToDocumentedCodes(t *testing.T) { + t.Parallel() + testutil.AssertFaultFixtures(t, "symlink", map[string]string{ + "add_symlink_response_failed_in_progress.xml": "in_progress", + }) +} From 46003877acf48aa0c8c3a4564127f326c64af92e Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 07:58:56 +0200 Subject: [PATCH 09/16] chore(testdata): use RFC 2606-reserved names in redacted fixture values info@example1.org and /example-new.com/ were redactions pointing at registrable domains; normalised to example.org / example.net. --- testdata/domain/update_domain_request.xml | 2 +- testdata/domain/update_domain_response_success.xml | 2 +- ...mailinglist_response_failed_no_active_listen_owner_found.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/testdata/domain/update_domain_request.xml b/testdata/domain/update_domain_request.xml index 8cf6d37..ad049cc 100644 --- a/testdata/domain/update_domain_request.xml +++ b/testdata/domain/update_domain_request.xml @@ -6,7 +6,7 @@ { "KasRequestParams": { "domain_name": "example.com", - "domain_path": "/example-new.com/", + "domain_path": "/example.net/", "redirect_status": "0", "php_version": "8.4", "is_active": "Y" diff --git a/testdata/domain/update_domain_response_success.xml b/testdata/domain/update_domain_response_success.xml index d464970..6fcbd74 100644 --- a/testdata/domain/update_domain_response_success.xml +++ b/testdata/domain/update_domain_response_success.xml @@ -23,7 +23,7 @@ domain_path - /example-new.com/ + /example.net/ redirect_status diff --git a/testdata/mailinglist/add_mailinglist_response_failed_no_active_listen_owner_found.xml b/testdata/mailinglist/add_mailinglist_response_failed_no_active_listen_owner_found.xml index 5125efb..2224685 100644 --- a/testdata/mailinglist/add_mailinglist_response_failed_no_active_listen_owner_found.xml +++ b/testdata/mailinglist/add_mailinglist_response_failed_no_active_listen_owner_found.xml @@ -5,7 +5,7 @@ SOAP-ENV:Server no_active_listen_owner_found KasApi - info@example1.org + info@example.org \ No newline at end of file From e337235199d2554241f9f21ece14bdb9c95467b0 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 07:58:56 +0200 Subject: [PATCH 10/16] docs(changelog): record the Low-severity review fixes --- CHANGELOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c37cd..a3973d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -575,6 +575,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Whole-codebase review follow-up (Low findings): + - `api.TokenSource.Invalidate` now reports whether the next + Credentials call can produce fresh credentials; `api.Client` skips + the auth-failure retry for a `StaticTokenSource` instead of + doubling the failing request with identical credentials. + - `transport.Client` no longer classifies `context.Canceled` / + `DeadlineExceeded` as retryable — the caller's cancellation is not + a transient server condition. + - The destructive-write `[y/N]` prompt goes to **stderr** so a + redirected stdout cannot swallow the question. + - A dispatched write whose audit sink fails keeps its true exit + classification: a KAS fault stays exit 2 (audit failure reported as + a warning), and a successful write renders its result before the + audit error surfaces as exit 1. + - Refused (`outcome=refused`, non-TTY without `--yes`) and declined + (`outcome=declined`, prompt answered no) destructive attempts now + leave an audit record; previously only dispatched writes and + dry-runs were traced. Documented in + `docs/usage/destructive-writes.md`. + - `auth.DecodeResponse` validates the credential token shape (40 + alphanumeric characters) before it is cached and persisted; the + error reports only the length, never the content. + - `auth.EncodeRequest` range-checks `Lifetime` against the documented + 1..30000 session_lifetime bound (0 = server default). + - New `config.ErrUnknownProfile` / `config.ErrMissingCredentials` + sentinels; `Resolve` failures are `errors.Is`-able instead of + string-matchable only. + - `dns` / `directoryprotection` / `server` / `usage` now keep their + `Caller` unexported (`c`), matching the other ten modules; the + exported `API` field was construction surface no caller used. + - `cronjob.FieldID` replaces three hardcoded `"cronjob_id"` literals. + - `internal/{softwareinstall,directoryprotection,database}/doc.go` + no longer name non-existent KAS actions (`get_softwareinstalls`, + `get_directoryprotections`, `get_database`); fixtures encoding + those non-actions in their filenames were renamed to the real + action plus a variant suffix + (`get_databases_{request,response_success}_single.xml`, + `get_directoryprotection_{request,response_success}_all.xml`, + `get_softwareinstall_{request,response_success}_all.xml`). + - The captured `testdata/{chown,ssl,symlink}/` fault fixtures (#125 + placeholders) are now anchored by one-line + `testutil.AssertFaultFixtures` tests instead of sitting + unreferenced. + - Fixture values `info@example1.org` and `/example-new.com/` replaced + with RFC 2606-reserved names (`example.org`, `example.net`). + - Whole-codebase review follow-up (Med findings): - `transport.Client` now enforces the 16-MB `soap.MaxResponseBytes` cap at the HTTP body read. Previously the cap lived only in the From 3e5e01b8977fe2609b2eff9957a02424ee2908e9 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 08:18:05 +0200 Subject: [PATCH 11/16] fix(transport,cli): re-review corrections to the Low-finding fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections from the post-fix re-review: the ctx-cancellation non-retry guard now keys on ctx.Err() instead of errors.Is(err, context.DeadlineExceeded) — a per-attempt HTTPClient.Timeout error also matches the latter but is a transient slow-server condition that must stay retryable (pinned by a new test); the same guard now also covers the body-read path. runWriteE emits the 'audit record not fully written' warning on the refusal and render-error paths too, matching the dispatch path. --- internal/cli/run.go | 7 ++++++- internal/transport/client.go | 14 ++++++++++---- internal/transport/client_test.go | 31 +++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/internal/cli/run.go b/internal/cli/run.go index 89faeae..27c7937 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -150,7 +150,9 @@ func runWriteE(opts *RootOptions, build func(args []string) (writeSpec, error)) Outcome: outcome, Fields: RedactParams(spec.params), } - _ = WriteAudit(stderr, auditFile, rec) + if werr := WriteAudit(stderr, auditFile, rec); werr != nil { + _, _ = fmt.Fprintf(stderr, "warning: audit record not fully written: %v\n", werr) + } } return err } @@ -180,6 +182,9 @@ func runWriteE(opts *RootOptions, build func(args []string) (writeSpec, error)) return APIError(derr, spec.action) } if rerr := Render(out, opts.Output, writeResult{Message: result}); rerr != nil { + if werr != nil { + _, _ = fmt.Fprintf(stderr, "warning: audit record not fully written: %v\n", werr) + } return UserError(rerr, "render") } if werr != nil { diff --git a/internal/transport/client.go b/internal/transport/client.go index 841658d..c89b7c1 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -167,10 +167,13 @@ func (c *Client) doOnce(ctx context.Context, endpoint string, body []byte) ([]by resp, err := c.HTTPClient.Do(req) if err != nil { - // A cancelled or timed-out context is the caller's decision to - // stop, not a transient server condition — retrying would only - // burn a backoff sleep before failing on the same ctx again. - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // The caller's cancelled/expired ctx is a decision to stop, not + // a transient server condition — retrying would only burn a + // backoff sleep before failing on the same ctx again. The check + // is on ctx.Err(), not errors.Is(err, context.DeadlineExceeded): + // a per-attempt HTTPClient.Timeout error also matches the + // latter, and that one IS a transient condition worth retrying. + if ctx.Err() != nil { return nil, fmt.Errorf("transport: post %s: %w", endpoint, err) } return nil, &retryableError{err: fmt.Errorf("transport: post %s: %w", endpoint, err)} @@ -182,6 +185,9 @@ func (c *Client) doOnce(ctx context.Context, endpoint string, body []byte) ([]by // so the memory-exhaustion guard must be enforced at this read. respBody, err := io.ReadAll(io.LimitReader(resp.Body, soap.MaxResponseBytes+1)) if err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("transport: read body: %w", err) + } return nil, &retryableError{err: fmt.Errorf("transport: read body: %w", err)} } if len(respBody) > soap.MaxResponseBytes { diff --git a/internal/transport/client_test.go b/internal/transport/client_test.go index de14013..931fa1e 100644 --- a/internal/transport/client_test.go +++ b/internal/transport/client_test.go @@ -427,3 +427,34 @@ func TestDoRejectsOversizedResponse(t *testing.T) { t.Errorf("requests = %d, want 1 (oversize must not be retried)", hits.Load()) } } + +// A per-attempt HTTPClient.Timeout error matches +// errors.Is(err, context.DeadlineExceeded) even though the caller's ctx +// is still alive; it is a transient slow-server condition and must stay +// retryable — only the caller's own cancellation suppresses the retry. +func TestDoRetriesOnClientTimeout(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if hits.Add(1) == 1 { + // Outlast the 100ms client timeout, then return so + // srv.Close does not wait on a stuck handler. + time.Sleep(500 * time.Millisecond) + return + } + _, _ = io.WriteString(w, "") + })) + defer srv.Close() + + c := newClient(srv, newFakeClock()) + c.HTTPClient.Timeout = 100 * time.Millisecond + resp, err := c.Do(context.Background(), srv.URL, []byte(sampleEnvelope)) + if err != nil { + t.Fatalf("Do: %v", err) + } + if string(resp) != "" { + t.Errorf("body = %q, want ", resp) + } + if hits.Load() != 2 { + t.Errorf("requests = %d, want 2 (client-timeout attempt retried once)", hits.Load()) + } +} From 016e6cf1eaaf2bfe7b5c030122f43fc66e7f43a3 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 12:47:12 +0200 Subject: [PATCH 12/16] fix(cli,transport,config,session,testutil): second-pass Med/Low review corrections Bad user input (positional-args failures, unknown root subcommand) now exits 1 instead of 2; 5xx responses carrying a SOAP fault pass through to the decoder instead of being retried blindly; AssertFaultFixtures rejects dead want-keys and the shared fault fixtures gain a dedicated anchor; config.Resolve wraps ErrUnknownProfile on the nil-config --profile path; session.Store.Load treats a zero expires_at as expired; cronjobs/ftpusers update bind their own flag sets; --dry-run help says "write command"; stale doc.go/comment claims corrected; mis-captured get_topleveldomains request fixture and out-of-convention dns/cronjob fixture names fixed. --- cmd/kasapi-cli/main.go | 1 + docs/cli/kasapi-cli.md | 2 +- docs/cli/kasapi-cli_accounts.md | 2 +- docs/cli/kasapi-cli_accounts_get.md | 2 +- docs/cli/kasapi-cli_accounts_list.md | 2 +- docs/cli/kasapi-cli_accounts_resources.md | 2 +- docs/cli/kasapi-cli_accounts_settings.md | 2 +- docs/cli/kasapi-cli_completion.md | 2 +- docs/cli/kasapi-cli_completion_bash.md | 2 +- docs/cli/kasapi-cli_completion_fish.md | 2 +- docs/cli/kasapi-cli_completion_powershell.md | 2 +- docs/cli/kasapi-cli_completion_zsh.md | 2 +- docs/cli/kasapi-cli_config.md | 2 +- docs/cli/kasapi-cli_config_add-profile.md | 2 +- docs/cli/kasapi-cli_config_init.md | 2 +- docs/cli/kasapi-cli_config_list-profiles.md | 2 +- docs/cli/kasapi-cli_config_path.md | 2 +- docs/cli/kasapi-cli_config_show.md | 2 +- docs/cli/kasapi-cli_config_use-profile.md | 2 +- docs/cli/kasapi-cli_cronjobs.md | 2 +- docs/cli/kasapi-cli_cronjobs_add.md | 10 ++-- docs/cli/kasapi-cli_cronjobs_delete.md | 2 +- docs/cli/kasapi-cli_cronjobs_get.md | 2 +- docs/cli/kasapi-cli_cronjobs_list.md | 2 +- docs/cli/kasapi-cli_cronjobs_update.md | 30 +++++----- docs/cli/kasapi-cli_databases.md | 2 +- docs/cli/kasapi-cli_databases_add.md | 2 +- docs/cli/kasapi-cli_databases_delete.md | 2 +- docs/cli/kasapi-cli_databases_get.md | 2 +- docs/cli/kasapi-cli_databases_list.md | 2 +- docs/cli/kasapi-cli_databases_update.md | 2 +- docs/cli/kasapi-cli_ddnsusers.md | 2 +- docs/cli/kasapi-cli_ddnsusers_add.md | 2 +- docs/cli/kasapi-cli_ddnsusers_delete.md | 2 +- docs/cli/kasapi-cli_ddnsusers_get.md | 2 +- docs/cli/kasapi-cli_ddnsusers_list.md | 2 +- docs/cli/kasapi-cli_ddnsusers_update.md | 2 +- docs/cli/kasapi-cli_directoryprotection.md | 2 +- .../cli/kasapi-cli_directoryprotection_add.md | 2 +- .../kasapi-cli_directoryprotection_delete.md | 2 +- .../kasapi-cli_directoryprotection_list.md | 2 +- .../kasapi-cli_directoryprotection_update.md | 2 +- docs/cli/kasapi-cli_dns.md | 2 +- docs/cli/kasapi-cli_dns_list.md | 2 +- docs/cli/kasapi-cli_domains.md | 2 +- docs/cli/kasapi-cli_domains_get.md | 2 +- docs/cli/kasapi-cli_domains_list.md | 2 +- docs/cli/kasapi-cli_ftpusers.md | 2 +- docs/cli/kasapi-cli_ftpusers_add.md | 6 +- docs/cli/kasapi-cli_ftpusers_delete.md | 2 +- docs/cli/kasapi-cli_ftpusers_get.md | 2 +- docs/cli/kasapi-cli_ftpusers_list.md | 2 +- docs/cli/kasapi-cli_ftpusers_update.md | 16 ++--- docs/cli/kasapi-cli_mail.md | 2 +- docs/cli/kasapi-cli_mail_accounts.md | 2 +- docs/cli/kasapi-cli_mail_accounts_add.md | 2 +- docs/cli/kasapi-cli_mail_accounts_delete.md | 2 +- docs/cli/kasapi-cli_mail_accounts_get.md | 2 +- docs/cli/kasapi-cli_mail_accounts_list.md | 2 +- docs/cli/kasapi-cli_mail_accounts_update.md | 2 +- docs/cli/kasapi-cli_mail_filters.md | 2 +- docs/cli/kasapi-cli_mail_filters_add.md | 2 +- docs/cli/kasapi-cli_mail_filters_delete.md | 2 +- docs/cli/kasapi-cli_mail_filters_list.md | 2 +- docs/cli/kasapi-cli_mail_forwards.md | 2 +- docs/cli/kasapi-cli_mail_forwards_add.md | 2 +- docs/cli/kasapi-cli_mail_forwards_delete.md | 2 +- docs/cli/kasapi-cli_mail_forwards_get.md | 2 +- docs/cli/kasapi-cli_mail_forwards_list.md | 2 +- docs/cli/kasapi-cli_mail_forwards_update.md | 2 +- docs/cli/kasapi-cli_mail_lists.md | 2 +- docs/cli/kasapi-cli_mail_lists_add.md | 2 +- docs/cli/kasapi-cli_mail_lists_delete.md | 2 +- docs/cli/kasapi-cli_mail_lists_get.md | 2 +- docs/cli/kasapi-cli_mail_lists_list.md | 2 +- docs/cli/kasapi-cli_mail_lists_update.md | 2 +- docs/cli/kasapi-cli_sambausers.md | 2 +- docs/cli/kasapi-cli_sambausers_add.md | 2 +- docs/cli/kasapi-cli_sambausers_delete.md | 2 +- docs/cli/kasapi-cli_sambausers_get.md | 2 +- docs/cli/kasapi-cli_sambausers_list.md | 2 +- docs/cli/kasapi-cli_sambausers_update.md | 2 +- docs/cli/kasapi-cli_server.md | 2 +- docs/cli/kasapi-cli_server_info.md | 2 +- docs/cli/kasapi-cli_sessions.md | 2 +- docs/cli/kasapi-cli_sessions_delete.md | 2 +- docs/cli/kasapi-cli_softwareinstalls.md | 2 +- docs/cli/kasapi-cli_softwareinstalls_get.md | 2 +- docs/cli/kasapi-cli_softwareinstalls_list.md | 2 +- docs/cli/kasapi-cli_subdomains.md | 2 +- docs/cli/kasapi-cli_subdomains_get.md | 2 +- docs/cli/kasapi-cli_subdomains_list.md | 2 +- docs/cli/kasapi-cli_tlds.md | 2 +- docs/cli/kasapi-cli_tlds_list.md | 2 +- docs/cli/kasapi-cli_usage.md | 2 +- docs/cli/kasapi-cli_usage_space-detail.md | 2 +- docs/cli/kasapi-cli_usage_space.md | 2 +- docs/cli/kasapi-cli_usage_traffic.md | 2 +- internal/api/fault_fixtures_test.go | 25 ++++++++ internal/cli/cronjobs.go | 59 +++++++++++++++---- internal/cli/ftpusers.go | 49 +++++++++++---- internal/cli/root.go | 41 ++++++++++++- internal/cli/root_test.go | 44 ++++++++++++++ internal/config/config_test.go | 14 +++++ internal/config/credentials.go | 5 +- internal/cronjob/doc.go | 5 +- internal/cronjob/write_test.go | 2 +- internal/ddns/ddns.go | 5 +- internal/dns/dns_test.go | 2 +- internal/ftpuser/doc.go | 10 ++-- internal/mailaccount/doc.go | 6 +- internal/session/store.go | 5 +- internal/session/store_test.go | 24 ++++++++ internal/testutil/testutil.go | 23 ++++++-- internal/transport/client.go | 10 ++++ internal/transport/client_test.go | 25 ++++++++ ... add_cronjob_response_success_warning.xml} | 0 ...tings_request_zone_host_and_record_id.xml} | 0 ...ponse_success_zone_host_and_record_id.xml} | 0 .../domain/get_topleveldomains_request.xml | 2 +- 120 files changed, 437 insertions(+), 168 deletions(-) create mode 100644 internal/api/fault_fixtures_test.go rename testdata/cronjob/{add_cronjob_response_warning.xml => add_cronjob_response_success_warning.xml} (100%) rename testdata/dns/{get_dns_settings_zone_host_and_record_id_request.xml => get_dns_settings_request_zone_host_and_record_id.xml} (100%) rename testdata/dns/{get_dns_settings_zone_host_and_record_id_response_success.xml => get_dns_settings_response_success_zone_host_and_record_id.xml} (100%) diff --git a/cmd/kasapi-cli/main.go b/cmd/kasapi-cli/main.go index 14b22c5..6c65e96 100644 --- a/cmd/kasapi-cli/main.go +++ b/cmd/kasapi-cli/main.go @@ -34,6 +34,7 @@ func main() { cli.NewConfigCmd(opts), cli.NewGenDocsCmd(), ) + cli.MarkArgErrorsAsUserErrors(root) if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, "kasapi-cli:", err) os.Exit(cli.CodeFor(err)) diff --git a/docs/cli/kasapi-cli.md b/docs/cli/kasapi-cli.md index 3084403..fa30b08 100644 --- a/docs/cli/kasapi-cli.md +++ b/docs/cli/kasapi-cli.md @@ -17,7 +17,7 @@ kasapi-cli [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output -h, --help help for kasapi-cli --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. diff --git a/docs/cli/kasapi-cli_accounts.md b/docs/cli/kasapi-cli_accounts.md index 90d80b5..06ec9be 100644 --- a/docs/cli/kasapi-cli_accounts.md +++ b/docs/cli/kasapi-cli_accounts.md @@ -15,7 +15,7 @@ Inspect KAS accounts owned by the authenticated login --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_accounts_get.md b/docs/cli/kasapi-cli_accounts_get.md index 15d4559..e1102da 100644 --- a/docs/cli/kasapi-cli_accounts_get.md +++ b/docs/cli/kasapi-cli_accounts_get.md @@ -19,7 +19,7 @@ kasapi-cli accounts get [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_accounts_list.md b/docs/cli/kasapi-cli_accounts_list.md index 65bd227..73cb1df 100644 --- a/docs/cli/kasapi-cli_accounts_list.md +++ b/docs/cli/kasapi-cli_accounts_list.md @@ -19,7 +19,7 @@ kasapi-cli accounts list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_accounts_resources.md b/docs/cli/kasapi-cli_accounts_resources.md index 1ede531..ec6eb55 100644 --- a/docs/cli/kasapi-cli_accounts_resources.md +++ b/docs/cli/kasapi-cli_accounts_resources.md @@ -19,7 +19,7 @@ kasapi-cli accounts resources [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_accounts_settings.md b/docs/cli/kasapi-cli_accounts_settings.md index f571e80..03ec835 100644 --- a/docs/cli/kasapi-cli_accounts_settings.md +++ b/docs/cli/kasapi-cli_accounts_settings.md @@ -19,7 +19,7 @@ kasapi-cli accounts settings [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_completion.md b/docs/cli/kasapi-cli_completion.md index 8855a8a..13b5612 100644 --- a/docs/cli/kasapi-cli_completion.md +++ b/docs/cli/kasapi-cli_completion.md @@ -21,7 +21,7 @@ See each sub-command's help for details on how to use the generated script. --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_completion_bash.md b/docs/cli/kasapi-cli_completion_bash.md index c107f51..a2017c5 100644 --- a/docs/cli/kasapi-cli_completion_bash.md +++ b/docs/cli/kasapi-cli_completion_bash.md @@ -44,7 +44,7 @@ kasapi-cli completion bash --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_completion_fish.md b/docs/cli/kasapi-cli_completion_fish.md index ad87526..aefc6ad 100644 --- a/docs/cli/kasapi-cli_completion_fish.md +++ b/docs/cli/kasapi-cli_completion_fish.md @@ -35,7 +35,7 @@ kasapi-cli completion fish [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_completion_powershell.md b/docs/cli/kasapi-cli_completion_powershell.md index f2a33dd..0eef938 100644 --- a/docs/cli/kasapi-cli_completion_powershell.md +++ b/docs/cli/kasapi-cli_completion_powershell.md @@ -32,7 +32,7 @@ kasapi-cli completion powershell [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_completion_zsh.md b/docs/cli/kasapi-cli_completion_zsh.md index ef559f2..2872683 100644 --- a/docs/cli/kasapi-cli_completion_zsh.md +++ b/docs/cli/kasapi-cli_completion_zsh.md @@ -46,7 +46,7 @@ kasapi-cli completion zsh [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_config.md b/docs/cli/kasapi-cli_config.md index 5c10898..450d221 100644 --- a/docs/cli/kasapi-cli_config.md +++ b/docs/cli/kasapi-cli_config.md @@ -15,7 +15,7 @@ Inspect and bootstrap the kasapi-cli configuration --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_config_add-profile.md b/docs/cli/kasapi-cli_config_add-profile.md index 4f8569b..bc44bb9 100644 --- a/docs/cli/kasapi-cli_config_add-profile.md +++ b/docs/cli/kasapi-cli_config_add-profile.md @@ -20,7 +20,7 @@ kasapi-cli config add-profile [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_config_init.md b/docs/cli/kasapi-cli_config_init.md index 17f61d8..2d9879b 100644 --- a/docs/cli/kasapi-cli_config_init.md +++ b/docs/cli/kasapi-cli_config_init.md @@ -21,7 +21,7 @@ kasapi-cli config init [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_config_list-profiles.md b/docs/cli/kasapi-cli_config_list-profiles.md index 5cff09a..8b0341d 100644 --- a/docs/cli/kasapi-cli_config_list-profiles.md +++ b/docs/cli/kasapi-cli_config_list-profiles.md @@ -19,7 +19,7 @@ kasapi-cli config list-profiles [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_config_path.md b/docs/cli/kasapi-cli_config_path.md index 10b23ee..1a5fb78 100644 --- a/docs/cli/kasapi-cli_config_path.md +++ b/docs/cli/kasapi-cli_config_path.md @@ -19,7 +19,7 @@ kasapi-cli config path [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_config_show.md b/docs/cli/kasapi-cli_config_show.md index 77988e8..aaa2081 100644 --- a/docs/cli/kasapi-cli_config_show.md +++ b/docs/cli/kasapi-cli_config_show.md @@ -19,7 +19,7 @@ kasapi-cli config show [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_config_use-profile.md b/docs/cli/kasapi-cli_config_use-profile.md index d1b8d48..c21e60e 100644 --- a/docs/cli/kasapi-cli_config_use-profile.md +++ b/docs/cli/kasapi-cli_config_use-profile.md @@ -19,7 +19,7 @@ kasapi-cli config use-profile [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_cronjobs.md b/docs/cli/kasapi-cli_cronjobs.md index bebaac0..89d72e3 100644 --- a/docs/cli/kasapi-cli_cronjobs.md +++ b/docs/cli/kasapi-cli_cronjobs.md @@ -15,7 +15,7 @@ Inspect and manage cronjobs (get/add/update/delete_cronjob) --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_cronjobs_add.md b/docs/cli/kasapi-cli_cronjobs_add.md index b10ffb3..d2b28b3 100644 --- a/docs/cli/kasapi-cli_cronjobs_add.md +++ b/docs/cli/kasapi-cli_cronjobs_add.md @@ -10,20 +10,20 @@ kasapi-cli cronjobs add --url --comment --minute --hour [fl ``` --active whether the cronjob is active; pass --active=false to disable (default true) - --comment string cronjob comment / label (required for add) + --comment string cronjob comment / label (required) --day-of-month string schedule day-of-month field (default "*") --day-of-week string schedule day-of-week field (0-7, Sun=0|7) (default "*") -h, --help help for add - --hour string schedule hour field (required for add) + --hour string schedule hour field (required) --http-password string HTTP basic-auth password for the call --http-user string HTTP basic-auth user for the call --mail-address string notification mail address (mail_adress) --mail-condition string when to send the notification mail --mail-subject string notification mail subject (default|comment) (default "default") - --minute string schedule minute field (required for add) + --minute string schedule minute field (required) --month string schedule month field (default "*") --protocol string request protocol (http|https) (default "https") - --url string URL to call (http_url; required for add) + --url string URL to call (http_url; required) ``` ### Options inherited from parent commands @@ -33,7 +33,7 @@ kasapi-cli cronjobs add --url --comment --minute --hour [fl --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_cronjobs_delete.md b/docs/cli/kasapi-cli_cronjobs_delete.md index 368958a..8d26c2d 100644 --- a/docs/cli/kasapi-cli_cronjobs_delete.md +++ b/docs/cli/kasapi-cli_cronjobs_delete.md @@ -19,7 +19,7 @@ kasapi-cli cronjobs delete [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_cronjobs_get.md b/docs/cli/kasapi-cli_cronjobs_get.md index 2e55904..531ed51 100644 --- a/docs/cli/kasapi-cli_cronjobs_get.md +++ b/docs/cli/kasapi-cli_cronjobs_get.md @@ -19,7 +19,7 @@ kasapi-cli cronjobs get [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_cronjobs_list.md b/docs/cli/kasapi-cli_cronjobs_list.md index 751d14f..a5f9069 100644 --- a/docs/cli/kasapi-cli_cronjobs_list.md +++ b/docs/cli/kasapi-cli_cronjobs_list.md @@ -19,7 +19,7 @@ kasapi-cli cronjobs list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_cronjobs_update.md b/docs/cli/kasapi-cli_cronjobs_update.md index 83a175a..c2ef8bf 100644 --- a/docs/cli/kasapi-cli_cronjobs_update.md +++ b/docs/cli/kasapi-cli_cronjobs_update.md @@ -9,21 +9,21 @@ kasapi-cli cronjobs update [schedule/mail flags] [flags] ### Options ``` - --active whether the cronjob is active; pass --active=false to disable (default true) - --comment string cronjob comment / label (required for add) - --day-of-month string schedule day-of-month field (default "*") - --day-of-week string schedule day-of-week field (0-7, Sun=0|7) (default "*") + --active replacement active state; pass --active=false to disable (default true) + --comment string replacement cronjob comment / label + --day-of-month string replacement schedule day-of-month field + --day-of-week string replacement schedule day-of-week field (0-7, Sun=0|7) -h, --help help for update - --hour string schedule hour field (required for add) - --http-password string HTTP basic-auth password for the call - --http-user string HTTP basic-auth user for the call - --mail-address string notification mail address (mail_adress) - --mail-condition string when to send the notification mail - --mail-subject string notification mail subject (default|comment) (default "default") - --minute string schedule minute field (required for add) - --month string schedule month field (default "*") - --protocol string request protocol (http|https) (default "https") - --url string URL to call (http_url; required for add) + --hour string replacement schedule hour field + --http-password string replacement HTTP basic-auth password for the call + --http-user string replacement HTTP basic-auth user for the call + --mail-address string replacement notification mail address (mail_adress) + --mail-condition string replacement notification-mail condition + --mail-subject string replacement notification mail subject (default|comment) + --minute string replacement schedule minute field + --month string replacement schedule month field + --protocol string replacement request protocol (http|https) + --url string replacement URL to call (http_url) ``` ### Options inherited from parent commands @@ -33,7 +33,7 @@ kasapi-cli cronjobs update [schedule/mail flags] [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_databases.md b/docs/cli/kasapi-cli_databases.md index e938226..7738e96 100644 --- a/docs/cli/kasapi-cli_databases.md +++ b/docs/cli/kasapi-cli_databases.md @@ -15,7 +15,7 @@ Inspect and manage databases (get/add/update/delete_database) --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_databases_add.md b/docs/cli/kasapi-cli_databases_add.md index 0e9909f..5282e04 100644 --- a/docs/cli/kasapi-cli_databases_add.md +++ b/docs/cli/kasapi-cli_databases_add.md @@ -34,7 +34,7 @@ kasapi-cli databases add --password --comment [--allowed-hosts [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_databases_get.md b/docs/cli/kasapi-cli_databases_get.md index b4c3cbe..107a554 100644 --- a/docs/cli/kasapi-cli_databases_get.md +++ b/docs/cli/kasapi-cli_databases_get.md @@ -19,7 +19,7 @@ kasapi-cli databases get [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_databases_list.md b/docs/cli/kasapi-cli_databases_list.md index 6df5ed6..bf8d094 100644 --- a/docs/cli/kasapi-cli_databases_list.md +++ b/docs/cli/kasapi-cli_databases_list.md @@ -19,7 +19,7 @@ kasapi-cli databases list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_databases_update.md b/docs/cli/kasapi-cli_databases_update.md index a16663a..ff47247 100644 --- a/docs/cli/kasapi-cli_databases_update.md +++ b/docs/cli/kasapi-cli_databases_update.md @@ -22,7 +22,7 @@ kasapi-cli databases update [password/comment/allowed-hosts fla --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_ddnsusers.md b/docs/cli/kasapi-cli_ddnsusers.md index 4ef37e0..1fe6be4 100644 --- a/docs/cli/kasapi-cli_ddnsusers.md +++ b/docs/cli/kasapi-cli_ddnsusers.md @@ -15,7 +15,7 @@ Inspect and manage DDNS users (get/add/update/delete_ddnsuser) --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_ddnsusers_add.md b/docs/cli/kasapi-cli_ddnsusers_add.md index b0c10cb..de06280 100644 --- a/docs/cli/kasapi-cli_ddnsusers_add.md +++ b/docs/cli/kasapi-cli_ddnsusers_add.md @@ -37,7 +37,7 @@ kasapi-cli ddnsusers add --password --zone --label --target-ip --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_ddnsusers_delete.md b/docs/cli/kasapi-cli_ddnsusers_delete.md index 608bfa7..f52e499 100644 --- a/docs/cli/kasapi-cli_ddnsusers_delete.md +++ b/docs/cli/kasapi-cli_ddnsusers_delete.md @@ -19,7 +19,7 @@ kasapi-cli ddnsusers delete [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_ddnsusers_get.md b/docs/cli/kasapi-cli_ddnsusers_get.md index 2d9ae86..b7c8099 100644 --- a/docs/cli/kasapi-cli_ddnsusers_get.md +++ b/docs/cli/kasapi-cli_ddnsusers_get.md @@ -19,7 +19,7 @@ kasapi-cli ddnsusers get [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_ddnsusers_list.md b/docs/cli/kasapi-cli_ddnsusers_list.md index 4d42e8e..2845c71 100644 --- a/docs/cli/kasapi-cli_ddnsusers_list.md +++ b/docs/cli/kasapi-cli_ddnsusers_list.md @@ -19,7 +19,7 @@ kasapi-cli ddnsusers list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_ddnsusers_update.md b/docs/cli/kasapi-cli_ddnsusers_update.md index 553d8a4..5121b13 100644 --- a/docs/cli/kasapi-cli_ddnsusers_update.md +++ b/docs/cli/kasapi-cli_ddnsusers_update.md @@ -24,7 +24,7 @@ kasapi-cli ddnsusers update [password/target/dual-stack flags] [f --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_directoryprotection.md b/docs/cli/kasapi-cli_directoryprotection.md index 96d36e7..32d04ba 100644 --- a/docs/cli/kasapi-cli_directoryprotection.md +++ b/docs/cli/kasapi-cli_directoryprotection.md @@ -15,7 +15,7 @@ Inspect and manage directory (htaccess) protections (get/add/update/delete_direc --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_directoryprotection_add.md b/docs/cli/kasapi-cli_directoryprotection_add.md index 92124cb..d4b00da 100644 --- a/docs/cli/kasapi-cli_directoryprotection_add.md +++ b/docs/cli/kasapi-cli_directoryprotection_add.md @@ -21,7 +21,7 @@ kasapi-cli directoryprotection add --password [--authname [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_directoryprotection_list.md b/docs/cli/kasapi-cli_directoryprotection_list.md index 8fc5a5b..fbbc679 100644 --- a/docs/cli/kasapi-cli_directoryprotection_list.md +++ b/docs/cli/kasapi-cli_directoryprotection_list.md @@ -20,7 +20,7 @@ kasapi-cli directoryprotection list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_directoryprotection_update.md b/docs/cli/kasapi-cli_directoryprotection_update.md index 4896a6e..1246443 100644 --- a/docs/cli/kasapi-cli_directoryprotection_update.md +++ b/docs/cli/kasapi-cli_directoryprotection_update.md @@ -21,7 +21,7 @@ kasapi-cli directoryprotection update [--password ] [--authnam --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_dns.md b/docs/cli/kasapi-cli_dns.md index 5d6f96b..cc9b90d 100644 --- a/docs/cli/kasapi-cli_dns.md +++ b/docs/cli/kasapi-cli_dns.md @@ -15,7 +15,7 @@ Inspect DNS records for a zone --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_dns_list.md b/docs/cli/kasapi-cli_dns_list.md index c480920..a7fcfb6 100644 --- a/docs/cli/kasapi-cli_dns_list.md +++ b/docs/cli/kasapi-cli_dns_list.md @@ -21,7 +21,7 @@ kasapi-cli dns list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_domains.md b/docs/cli/kasapi-cli_domains.md index 8bb0ba0..ee958c7 100644 --- a/docs/cli/kasapi-cli_domains.md +++ b/docs/cli/kasapi-cli_domains.md @@ -15,7 +15,7 @@ Inspect domains owned by the authenticated account --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_domains_get.md b/docs/cli/kasapi-cli_domains_get.md index ef03887..a80562e 100644 --- a/docs/cli/kasapi-cli_domains_get.md +++ b/docs/cli/kasapi-cli_domains_get.md @@ -19,7 +19,7 @@ kasapi-cli domains get [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_domains_list.md b/docs/cli/kasapi-cli_domains_list.md index b1690c3..e9ce923 100644 --- a/docs/cli/kasapi-cli_domains_list.md +++ b/docs/cli/kasapi-cli_domains_list.md @@ -19,7 +19,7 @@ kasapi-cli domains list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_ftpusers.md b/docs/cli/kasapi-cli_ftpusers.md index 2572160..992bd10 100644 --- a/docs/cli/kasapi-cli_ftpusers.md +++ b/docs/cli/kasapi-cli_ftpusers.md @@ -15,7 +15,7 @@ Inspect and manage FTP users (get/add/update/delete_ftpuser) --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_ftpusers_add.md b/docs/cli/kasapi-cli_ftpusers_add.md index ce233f0..519b596 100644 --- a/docs/cli/kasapi-cli_ftpusers_add.md +++ b/docs/cli/kasapi-cli_ftpusers_add.md @@ -9,9 +9,9 @@ kasapi-cli ftpusers add --password --comment [flags] ### Options ``` - --comment string user comment / label (required for add) + --comment string user comment / label (required) -h, --help help for add - --password string FTP password (required for add; new password for update) + --password string FTP password (required) --path string home directory the user is jailed to (ftp_path) (default "/") --permission-list grant directory-list access; pass --permission-list=false to deny (default true) --permission-read grant read access; pass --permission-read=false to deny (default true) @@ -26,7 +26,7 @@ kasapi-cli ftpusers add --password --comment [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_ftpusers_delete.md b/docs/cli/kasapi-cli_ftpusers_delete.md index 7507604..a576855 100644 --- a/docs/cli/kasapi-cli_ftpusers_delete.md +++ b/docs/cli/kasapi-cli_ftpusers_delete.md @@ -19,7 +19,7 @@ kasapi-cli ftpusers delete [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_ftpusers_get.md b/docs/cli/kasapi-cli_ftpusers_get.md index adaf910..be410a1 100644 --- a/docs/cli/kasapi-cli_ftpusers_get.md +++ b/docs/cli/kasapi-cli_ftpusers_get.md @@ -19,7 +19,7 @@ kasapi-cli ftpusers get [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_ftpusers_list.md b/docs/cli/kasapi-cli_ftpusers_list.md index 9a6d1dc..b859c3a 100644 --- a/docs/cli/kasapi-cli_ftpusers_list.md +++ b/docs/cli/kasapi-cli_ftpusers_list.md @@ -19,7 +19,7 @@ kasapi-cli ftpusers list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_ftpusers_update.md b/docs/cli/kasapi-cli_ftpusers_update.md index 2a862db..181c221 100644 --- a/docs/cli/kasapi-cli_ftpusers_update.md +++ b/docs/cli/kasapi-cli_ftpusers_update.md @@ -9,14 +9,14 @@ kasapi-cli ftpusers update [password/permission flags] [flags] ### Options ``` - --comment string user comment / label (required for add) + --comment string replacement user comment / label -h, --help help for update - --password string FTP password (required for add; new password for update) - --path string home directory the user is jailed to (ftp_path) (default "/") - --permission-list grant directory-list access; pass --permission-list=false to deny (default true) - --permission-read grant read access; pass --permission-read=false to deny (default true) - --permission-write grant write access; pass --permission-write=false to deny (default true) - --virus-clamav enable ClamAV scanning; pass --virus-clamav=false to disable (default true) + --password string replacement FTP password (sent as ftp_new_password) + --path string replacement home directory the user is jailed to (ftp_path) + --permission-list replacement directory-list access; pass --permission-list=false to deny (default true) + --permission-read replacement read access; pass --permission-read=false to deny (default true) + --permission-write replacement write access; pass --permission-write=false to deny (default true) + --virus-clamav replacement ClamAV scanning; pass --virus-clamav=false to disable (default true) ``` ### Options inherited from parent commands @@ -26,7 +26,7 @@ kasapi-cli ftpusers update [password/permission flags] [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail.md b/docs/cli/kasapi-cli_mail.md index bc4a36a..b9d258a 100644 --- a/docs/cli/kasapi-cli_mail.md +++ b/docs/cli/kasapi-cli_mail.md @@ -15,7 +15,7 @@ Inspect mail accounts and filters; inspect and manage forwards and mailing lists --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_accounts.md b/docs/cli/kasapi-cli_mail_accounts.md index b683a5c..3452bbb 100644 --- a/docs/cli/kasapi-cli_mail_accounts.md +++ b/docs/cli/kasapi-cli_mail_accounts.md @@ -15,7 +15,7 @@ Inspect and manage mail accounts (get/add/update/delete_mailaccount) --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_accounts_add.md b/docs/cli/kasapi-cli_mail_accounts_add.md index 44557f2..3d29bf7 100644 --- a/docs/cli/kasapi-cli_mail_accounts_add.md +++ b/docs/cli/kasapi-cli_mail_accounts_add.md @@ -45,7 +45,7 @@ kasapi-cli mail accounts add
--password [field flags] [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_accounts_delete.md b/docs/cli/kasapi-cli_mail_accounts_delete.md index 6ab52c9..0121e0a 100644 --- a/docs/cli/kasapi-cli_mail_accounts_delete.md +++ b/docs/cli/kasapi-cli_mail_accounts_delete.md @@ -19,7 +19,7 @@ kasapi-cli mail accounts delete [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_accounts_get.md b/docs/cli/kasapi-cli_mail_accounts_get.md index 9ee0ebd..f1dc659 100644 --- a/docs/cli/kasapi-cli_mail_accounts_get.md +++ b/docs/cli/kasapi-cli_mail_accounts_get.md @@ -19,7 +19,7 @@ kasapi-cli mail accounts get [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_accounts_list.md b/docs/cli/kasapi-cli_mail_accounts_list.md index 5b01d1c..3568c7a 100644 --- a/docs/cli/kasapi-cli_mail_accounts_list.md +++ b/docs/cli/kasapi-cli_mail_accounts_list.md @@ -19,7 +19,7 @@ kasapi-cli mail accounts list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_accounts_update.md b/docs/cli/kasapi-cli_mail_accounts_update.md index 2af6460..2de778f 100644 --- a/docs/cli/kasapi-cli_mail_accounts_update.md +++ b/docs/cli/kasapi-cli_mail_accounts_update.md @@ -35,7 +35,7 @@ kasapi-cli mail accounts update [field flags] [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_filters.md b/docs/cli/kasapi-cli_mail_filters.md index 400a31e..42c5dbf 100644 --- a/docs/cli/kasapi-cli_mail_filters.md +++ b/docs/cli/kasapi-cli_mail_filters.md @@ -15,7 +15,7 @@ Inspect and manage mail standard filters (get/add/delete_mailstandardfilter) --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_filters_add.md b/docs/cli/kasapi-cli_mail_filters_add.md index a1589eb..1e01069 100644 --- a/docs/cli/kasapi-cli_mail_filters_add.md +++ b/docs/cli/kasapi-cli_mail_filters_add.md @@ -29,7 +29,7 @@ kasapi-cli mail filters add --filter [--filter ...] [f --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_filters_delete.md b/docs/cli/kasapi-cli_mail_filters_delete.md index cbb8bd9..ed090d0 100644 --- a/docs/cli/kasapi-cli_mail_filters_delete.md +++ b/docs/cli/kasapi-cli_mail_filters_delete.md @@ -31,7 +31,7 @@ kasapi-cli mail filters delete [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_filters_list.md b/docs/cli/kasapi-cli_mail_filters_list.md index 8e7f1da..a774206 100644 --- a/docs/cli/kasapi-cli_mail_filters_list.md +++ b/docs/cli/kasapi-cli_mail_filters_list.md @@ -19,7 +19,7 @@ kasapi-cli mail filters list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_forwards.md b/docs/cli/kasapi-cli_mail_forwards.md index 344a15f..8946bf4 100644 --- a/docs/cli/kasapi-cli_mail_forwards.md +++ b/docs/cli/kasapi-cli_mail_forwards.md @@ -15,7 +15,7 @@ Inspect and manage mail forwards (get/add/update/delete_mailforward) --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_forwards_add.md b/docs/cli/kasapi-cli_mail_forwards_add.md index d6be65d..ac3c48b 100644 --- a/docs/cli/kasapi-cli_mail_forwards_add.md +++ b/docs/cli/kasapi-cli_mail_forwards_add.md @@ -20,7 +20,7 @@ kasapi-cli mail forwards add
--target [--target ...] [fla --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_forwards_delete.md b/docs/cli/kasapi-cli_mail_forwards_delete.md index 1781fae..f8281c5 100644 --- a/docs/cli/kasapi-cli_mail_forwards_delete.md +++ b/docs/cli/kasapi-cli_mail_forwards_delete.md @@ -19,7 +19,7 @@ kasapi-cli mail forwards delete
[flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_forwards_get.md b/docs/cli/kasapi-cli_mail_forwards_get.md index 1980c31..1edb87a 100644 --- a/docs/cli/kasapi-cli_mail_forwards_get.md +++ b/docs/cli/kasapi-cli_mail_forwards_get.md @@ -19,7 +19,7 @@ kasapi-cli mail forwards get [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_forwards_list.md b/docs/cli/kasapi-cli_mail_forwards_list.md index 71cb7c8..95682f4 100644 --- a/docs/cli/kasapi-cli_mail_forwards_list.md +++ b/docs/cli/kasapi-cli_mail_forwards_list.md @@ -19,7 +19,7 @@ kasapi-cli mail forwards list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_forwards_update.md b/docs/cli/kasapi-cli_mail_forwards_update.md index 3f3e3fe..2f00f45 100644 --- a/docs/cli/kasapi-cli_mail_forwards_update.md +++ b/docs/cli/kasapi-cli_mail_forwards_update.md @@ -20,7 +20,7 @@ kasapi-cli mail forwards update
--target [--target ...] [ --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_lists.md b/docs/cli/kasapi-cli_mail_lists.md index 5cebdb3..6c9f89a 100644 --- a/docs/cli/kasapi-cli_mail_lists.md +++ b/docs/cli/kasapi-cli_mail_lists.md @@ -15,7 +15,7 @@ Inspect and manage mailing lists (get/add/update/delete_mailinglist) --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_lists_add.md b/docs/cli/kasapi-cli_mail_lists_add.md index 10eb7df..c8375d5 100644 --- a/docs/cli/kasapi-cli_mail_lists_add.md +++ b/docs/cli/kasapi-cli_mail_lists_add.md @@ -21,7 +21,7 @@ kasapi-cli mail lists add --domain --password [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_lists_delete.md b/docs/cli/kasapi-cli_mail_lists_delete.md index 21881bf..8b199ee 100644 --- a/docs/cli/kasapi-cli_mail_lists_delete.md +++ b/docs/cli/kasapi-cli_mail_lists_delete.md @@ -19,7 +19,7 @@ kasapi-cli mail lists delete [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_lists_get.md b/docs/cli/kasapi-cli_mail_lists_get.md index 52667a1..fcd4ad4 100644 --- a/docs/cli/kasapi-cli_mail_lists_get.md +++ b/docs/cli/kasapi-cli_mail_lists_get.md @@ -19,7 +19,7 @@ kasapi-cli mail lists get [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_lists_list.md b/docs/cli/kasapi-cli_mail_lists_list.md index 2296b21..a16a04b 100644 --- a/docs/cli/kasapi-cli_mail_lists_list.md +++ b/docs/cli/kasapi-cli_mail_lists_list.md @@ -19,7 +19,7 @@ kasapi-cli mail lists list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_mail_lists_update.md b/docs/cli/kasapi-cli_mail_lists_update.md index ed779e3..2e8a8ee 100644 --- a/docs/cli/kasapi-cli_mail_lists_update.md +++ b/docs/cli/kasapi-cli_mail_lists_update.md @@ -23,7 +23,7 @@ kasapi-cli mail lists update [--subscriber ...] [--restrict-post --comment --path

[flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_sambausers_delete.md b/docs/cli/kasapi-cli_sambausers_delete.md index db746cb..02f6265 100644 --- a/docs/cli/kasapi-cli_sambausers_delete.md +++ b/docs/cli/kasapi-cli_sambausers_delete.md @@ -19,7 +19,7 @@ kasapi-cli sambausers delete [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_sambausers_get.md b/docs/cli/kasapi-cli_sambausers_get.md index 5e31cd4..582e782 100644 --- a/docs/cli/kasapi-cli_sambausers_get.md +++ b/docs/cli/kasapi-cli_sambausers_get.md @@ -19,7 +19,7 @@ kasapi-cli sambausers get [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_sambausers_list.md b/docs/cli/kasapi-cli_sambausers_list.md index 6f67fd8..25640d1 100644 --- a/docs/cli/kasapi-cli_sambausers_list.md +++ b/docs/cli/kasapi-cli_sambausers_list.md @@ -19,7 +19,7 @@ kasapi-cli sambausers list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_sambausers_update.md b/docs/cli/kasapi-cli_sambausers_update.md index 8da8cb1..c654fb3 100644 --- a/docs/cli/kasapi-cli_sambausers_update.md +++ b/docs/cli/kasapi-cli_sambausers_update.md @@ -22,7 +22,7 @@ kasapi-cli sambausers update [password/path flags] [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_server.md b/docs/cli/kasapi-cli_server.md index 8e23f40..634a532 100644 --- a/docs/cli/kasapi-cli_server.md +++ b/docs/cli/kasapi-cli_server.md @@ -15,7 +15,7 @@ Inspect the host server kasapi-cli is talking to --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_server_info.md b/docs/cli/kasapi-cli_server_info.md index 7366a0c..b1f4610 100644 --- a/docs/cli/kasapi-cli_server_info.md +++ b/docs/cli/kasapi-cli_server_info.md @@ -19,7 +19,7 @@ kasapi-cli server info [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_sessions.md b/docs/cli/kasapi-cli_sessions.md index 0b05896..c9cc397 100644 --- a/docs/cli/kasapi-cli_sessions.md +++ b/docs/cli/kasapi-cli_sessions.md @@ -21,7 +21,7 @@ add_session is not a separate endpoint — it is the KasAuth credential-token fl --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_sessions_delete.md b/docs/cli/kasapi-cli_sessions_delete.md index 22919e0..ec35d82 100644 --- a/docs/cli/kasapi-cli_sessions_delete.md +++ b/docs/cli/kasapi-cli_sessions_delete.md @@ -25,7 +25,7 @@ kasapi-cli sessions delete [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_softwareinstalls.md b/docs/cli/kasapi-cli_softwareinstalls.md index b3cf00c..085c8b5 100644 --- a/docs/cli/kasapi-cli_softwareinstalls.md +++ b/docs/cli/kasapi-cli_softwareinstalls.md @@ -15,7 +15,7 @@ Inspect installable software templates (get_softwareinstall) --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_softwareinstalls_get.md b/docs/cli/kasapi-cli_softwareinstalls_get.md index 3928a6d..fcf6ec9 100644 --- a/docs/cli/kasapi-cli_softwareinstalls_get.md +++ b/docs/cli/kasapi-cli_softwareinstalls_get.md @@ -19,7 +19,7 @@ kasapi-cli softwareinstalls get [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_softwareinstalls_list.md b/docs/cli/kasapi-cli_softwareinstalls_list.md index 0f13948..99d77b7 100644 --- a/docs/cli/kasapi-cli_softwareinstalls_list.md +++ b/docs/cli/kasapi-cli_softwareinstalls_list.md @@ -19,7 +19,7 @@ kasapi-cli softwareinstalls list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_subdomains.md b/docs/cli/kasapi-cli_subdomains.md index fedc7bf..93a2bf7 100644 --- a/docs/cli/kasapi-cli_subdomains.md +++ b/docs/cli/kasapi-cli_subdomains.md @@ -15,7 +15,7 @@ Inspect subdomains owned by the authenticated account --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_subdomains_get.md b/docs/cli/kasapi-cli_subdomains_get.md index 7559fed..164dedc 100644 --- a/docs/cli/kasapi-cli_subdomains_get.md +++ b/docs/cli/kasapi-cli_subdomains_get.md @@ -19,7 +19,7 @@ kasapi-cli subdomains get [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_subdomains_list.md b/docs/cli/kasapi-cli_subdomains_list.md index 15794c3..adbdd72 100644 --- a/docs/cli/kasapi-cli_subdomains_list.md +++ b/docs/cli/kasapi-cli_subdomains_list.md @@ -19,7 +19,7 @@ kasapi-cli subdomains list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_tlds.md b/docs/cli/kasapi-cli_tlds.md index 457bc54..a4e210b 100644 --- a/docs/cli/kasapi-cli_tlds.md +++ b/docs/cli/kasapi-cli_tlds.md @@ -15,7 +15,7 @@ Inspect the catalog of registrable top-level domains --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_tlds_list.md b/docs/cli/kasapi-cli_tlds_list.md index fc3b350..61cf92d 100644 --- a/docs/cli/kasapi-cli_tlds_list.md +++ b/docs/cli/kasapi-cli_tlds_list.md @@ -19,7 +19,7 @@ kasapi-cli tlds list [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_usage.md b/docs/cli/kasapi-cli_usage.md index 8b202d6..bd829b4 100644 --- a/docs/cli/kasapi-cli_usage.md +++ b/docs/cli/kasapi-cli_usage.md @@ -15,7 +15,7 @@ Inspect webspace and traffic counters --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_usage_space-detail.md b/docs/cli/kasapi-cli_usage_space-detail.md index 749ae8b..23e170e 100644 --- a/docs/cli/kasapi-cli_usage_space-detail.md +++ b/docs/cli/kasapi-cli_usage_space-detail.md @@ -20,7 +20,7 @@ kasapi-cli usage space-detail [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_usage_space.md b/docs/cli/kasapi-cli_usage_space.md index 8608cb2..9eb75b0 100644 --- a/docs/cli/kasapi-cli_usage_space.md +++ b/docs/cli/kasapi-cli_usage_space.md @@ -19,7 +19,7 @@ kasapi-cli usage space [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/docs/cli/kasapi-cli_usage_traffic.md b/docs/cli/kasapi-cli_usage_traffic.md index e1e757b..5465d1e 100644 --- a/docs/cli/kasapi-cli_usage_traffic.md +++ b/docs/cli/kasapi-cli_usage_traffic.md @@ -21,7 +21,7 @@ kasapi-cli usage traffic [flags] --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. --config string path to the kasapi-cli config file (overrides the default location) - --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --dry-run preview a write command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output --login string KAS login (overrides config and KAS_LOGIN) --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. -o, --output string output format: json|yaml|table (default table) diff --git a/internal/api/fault_fixtures_test.go b/internal/api/fault_fixtures_test.go new file mode 100644 index 0000000..fd48ce6 --- /dev/null +++ b/internal/api/fault_fixtures_test.go @@ -0,0 +1,25 @@ +package api_test + +import ( + "testing" + + "github.com/chmmou/kasapi-cli/internal/api" + "github.com/chmmou/kasapi-cli/internal/testutil" +) + +// TestSharedFaultFixtures anchors the shared top-level testdata/ +// response_failed_*.xml set (the cross-module session/auth/action +// faults) to the KAS contract, pinning each fixture to the api.Code* +// constant the error helpers classify it by. Module-specific fault +// fixtures are anchored by the per-module fault tests instead. +func TestSharedFaultFixtures(t *testing.T) { + t.Parallel() + testutil.AssertFaultFixtures(t, "", map[string]string{ + "response_failed_no_auth.xml": api.CodeNoAuth, + "response_failed_kas_session_invalid.xml": api.CodeSessionInvalid, + "response_failed_got_no_login_data.xml": api.CodeGotNoLoginData, + "response_failed_kas_access_forbidden.xml": api.CodeAccessForbidden, + "response_failed_no_action.xml": api.CodeNoAction, + "response_failed_unkown_action.xml": api.CodeUnknownAction, + }) +} diff --git a/internal/cli/cronjobs.go b/internal/cli/cronjobs.go index fb6c3ac..317c2e6 100644 --- a/internal/cli/cronjobs.go +++ b/internal/cli/cronjobs.go @@ -52,10 +52,9 @@ func newCronjobsGetCmd(opts *RootOptions) *cobra.Command { } } -// cronjobWriteFlags binds the shared add_cronjob / update_cronjob -// request fields to a command. The same flag set serves both: add -// reads every value (defaults included), update sends only the flags -// the user explicitly changed (see cronjobChangedFields). +// cronjobWriteFlags binds the add_cronjob request fields to a command. +// The update subcommand binds its own cronjobUpdateFlags so its --help +// does not advertise add defaults or "required for add" texts. type cronjobWriteFlags struct { protocol string url string @@ -76,10 +75,10 @@ type cronjobWriteFlags struct { func (f *cronjobWriteFlags) bind(cmd *cobra.Command) { fl := cmd.Flags() fl.StringVar(&f.protocol, "protocol", "https", "request protocol (http|https)") - fl.StringVar(&f.url, "url", "", "URL to call (http_url; required for add)") - fl.StringVar(&f.comment, "comment", "", "cronjob comment / label (required for add)") - fl.StringVar(&f.minute, "minute", "", "schedule minute field (required for add)") - fl.StringVar(&f.hour, "hour", "", "schedule hour field (required for add)") + fl.StringVar(&f.url, "url", "", "URL to call (http_url; required)") + fl.StringVar(&f.comment, "comment", "", "cronjob comment / label (required)") + fl.StringVar(&f.minute, "minute", "", "schedule minute field (required)") + fl.StringVar(&f.hour, "hour", "", "schedule hour field (required)") fl.StringVar(&f.dayOfMonth, "day-of-month", "*", "schedule day-of-month field") fl.StringVar(&f.month, "month", "*", "schedule month field") fl.StringVar(&f.dayOfWeek, "day-of-week", "*", "schedule day-of-week field (0-7, Sun=0|7)") @@ -153,13 +152,53 @@ func newCronjobsAddCmd(opts *RootOptions) *cobra.Command { return cmd } +// cronjobUpdateFlags binds the update_cronjob mutable surface. +// Disjoint from cronjobWriteFlags so update's --help describes the +// flags as replacements without the add defaults ("https", "*", +// "default") that update never sends unless explicitly set — the same +// split the database/mailaccount/ddnsuser/mailinglist slices use. +type cronjobUpdateFlags struct { + protocol string + url string + comment string + minute string + hour string + dayOfMonth string + month string + dayOfWeek string + httpUser string + httpPassword string + mailAddress string + mailCondition string + mailSubject string + active bool +} + +func (f *cronjobUpdateFlags) bind(cmd *cobra.Command) { + fl := cmd.Flags() + fl.StringVar(&f.protocol, "protocol", "", "replacement request protocol (http|https)") + fl.StringVar(&f.url, "url", "", "replacement URL to call (http_url)") + fl.StringVar(&f.comment, "comment", "", "replacement cronjob comment / label") + fl.StringVar(&f.minute, "minute", "", "replacement schedule minute field") + fl.StringVar(&f.hour, "hour", "", "replacement schedule hour field") + fl.StringVar(&f.dayOfMonth, "day-of-month", "", "replacement schedule day-of-month field") + fl.StringVar(&f.month, "month", "", "replacement schedule month field") + fl.StringVar(&f.dayOfWeek, "day-of-week", "", "replacement schedule day-of-week field (0-7, Sun=0|7)") + fl.StringVar(&f.httpUser, "http-user", "", "replacement HTTP basic-auth user for the call") + fl.StringVar(&f.httpPassword, "http-password", "", "replacement HTTP basic-auth password for the call") + fl.StringVar(&f.mailAddress, "mail-address", "", "replacement notification mail address (mail_adress)") + fl.StringVar(&f.mailCondition, "mail-condition", "", "replacement notification-mail condition") + fl.StringVar(&f.mailSubject, "mail-subject", "", "replacement notification mail subject (default|comment)") + fl.BoolVar(&f.active, "active", true, "replacement active state; pass --active=false to disable") +} + // cronjobChangedFields collects only the write flags the user // explicitly set into the update_cronjob field map (keyed on the // cronjob.Field* constants). Each field is a wholesale replacement and // an empty value is a meaningful set, so presence is keyed on cobra // Changed, not on the value being non-empty — the same pattern the // mailing-list update uses. -func cronjobChangedFields(cmd *cobra.Command, f *cronjobWriteFlags) map[string]string { +func cronjobChangedFields(cmd *cobra.Command, f *cronjobUpdateFlags) map[string]string { fields := map[string]string{} // flag name -> {KAS request key, current flag value}. for flag, kv := range map[string][2]string{ @@ -188,7 +227,7 @@ func cronjobChangedFields(cmd *cobra.Command, f *cronjobWriteFlags) map[string]s } func newCronjobsUpdateCmd(opts *RootOptions) *cobra.Command { - f := &cronjobWriteFlags{} + f := &cronjobUpdateFlags{} cmd := &cobra.Command{ Use: "update [schedule/mail flags]", Short: "Replace mutable fields of a cronjob (update_cronjob)", diff --git a/internal/cli/ftpusers.go b/internal/cli/ftpusers.go index eb34b18..7421b83 100644 --- a/internal/cli/ftpusers.go +++ b/internal/cli/ftpusers.go @@ -52,13 +52,10 @@ func newFTPUsersGetCmd(opts *RootOptions) *cobra.Command { } } -// ftpuserWriteFlags binds the shared add_ftpuser / update_ftpuser -// request fields to a command. The same flag set serves both: add -// reads every value (defaults included), update sends only the flags -// the user explicitly changed (see ftpuserChangedFields). The password -// flag maps to a different KAS key per action (add_ftpuser: -// ftp_password, update_ftpuser: ftp_new_password) — see spec() and -// ftpuserChangedFields. +// ftpuserWriteFlags binds the add_ftpuser request fields to a command. +// The password flag maps to the add-only ftp_password key — the update +// subcommand binds its own ftpuserUpdateFlags so its --help does not +// advertise add defaults or "required for add" texts. type ftpuserWriteFlags struct { password string comment string @@ -71,8 +68,8 @@ type ftpuserWriteFlags struct { func (f *ftpuserWriteFlags) bind(cmd *cobra.Command) { fl := cmd.Flags() - fl.StringVar(&f.password, "password", "", "FTP password (required for add; new password for update)") - fl.StringVar(&f.comment, "comment", "", "user comment / label (required for add)") + fl.StringVar(&f.password, "password", "", "FTP password (required)") + fl.StringVar(&f.comment, "comment", "", "user comment / label (required)") fl.StringVar(&f.path, "path", "/", "home directory the user is jailed to (ftp_path)") fl.BoolVar(&f.permRead, "permission-read", true, "grant read access; pass --permission-read=false to deny") fl.BoolVar(&f.permWrite, "permission-write", true, "grant write access; pass --permission-write=false to deny") @@ -125,6 +122,36 @@ func newFTPUsersAddCmd(opts *RootOptions) *cobra.Command { return cmd } +// ftpuserUpdateFlags binds the update_ftpuser mutable surface. +// Disjoint from ftpuserWriteFlags so update's --help describes the +// flags as replacements (not "required for add") and does not +// advertise add defaults that update never sends — the same split the +// database/mailaccount/ddnsuser/mailinglist slices use. +// +// The password flag maps to ftp_new_password on this subcommand (the +// update_ftpuser key) rather than the add-only ftp_password — see +// ftpuserChangedFields. +type ftpuserUpdateFlags struct { + password string + comment string + path string + permRead bool + permWrite bool + permList bool + virusClam bool +} + +func (f *ftpuserUpdateFlags) bind(cmd *cobra.Command) { + fl := cmd.Flags() + fl.StringVar(&f.password, "password", "", "replacement FTP password (sent as ftp_new_password)") + fl.StringVar(&f.comment, "comment", "", "replacement user comment / label") + fl.StringVar(&f.path, "path", "", "replacement home directory the user is jailed to (ftp_path)") + fl.BoolVar(&f.permRead, "permission-read", true, "replacement read access; pass --permission-read=false to deny") + fl.BoolVar(&f.permWrite, "permission-write", true, "replacement write access; pass --permission-write=false to deny") + fl.BoolVar(&f.permList, "permission-list", true, "replacement directory-list access; pass --permission-list=false to deny") + fl.BoolVar(&f.virusClam, "virus-clamav", true, "replacement ClamAV scanning; pass --virus-clamav=false to disable") +} + // ftpuserChangedFields collects only the write flags the user // explicitly set into the update_ftpuser field map (keyed on the // ftpuser.Field* constants). Each field is a wholesale replacement and @@ -132,7 +159,7 @@ func newFTPUsersAddCmd(opts *RootOptions) *cobra.Command { // Changed, not on the value being non-empty — the same pattern the // cronjob update uses. The password flag maps to ftp_new_password here // (update_ftpuser's key) rather than the add-only ftp_password. -func ftpuserChangedFields(cmd *cobra.Command, f *ftpuserWriteFlags) map[string]string { +func ftpuserChangedFields(cmd *cobra.Command, f *ftpuserUpdateFlags) map[string]string { fields := map[string]string{} if cmd.Flags().Changed("password") { fields[ftpuser.FieldNewPassword] = f.password @@ -158,7 +185,7 @@ func ftpuserChangedFields(cmd *cobra.Command, f *ftpuserWriteFlags) map[string]s } func newFTPUsersUpdateCmd(opts *RootOptions) *cobra.Command { - f := &ftpuserWriteFlags{} + f := &ftpuserUpdateFlags{} cmd := &cobra.Command{ Use: "update [password/permission flags]", Short: "Replace mutable fields of an FTP user (update_ftpuser)", diff --git a/internal/cli/root.go b/internal/cli/root.go index 1e448f7..73be270 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "strings" @@ -62,6 +63,20 @@ func NewRootCmd() (*cobra.Command, *RootOptions) { opts.Output = f return nil }, + // Replicates cobra's root-level legacyArgs "unknown command" + // rejection, but as a UserError so `kasapi-cli nonsense` exits 1 + // (bad user input) instead of falling through CodeFor to the + // API-error exit 2. + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + msg := fmt.Sprintf("unknown command %q for %q", args[0], cmd.CommandPath()) + if s := cmd.SuggestionsFor(args[0]); len(s) > 0 { + msg += fmt.Sprintf("\n\nDid you mean this?\n\t%s\n", strings.Join(s, "\n\t")) + } + return UserError(errors.New(msg), "") + }, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, @@ -97,7 +112,7 @@ func NewRootCmd() (*cobra.Command, *RootOptions) { "append a JSON-Lines audit record for each write action to this file "+ "(also KAS_AUDIT_LOG); a logfmt line always goes to stderr regardless") pf.BoolVar(&opts.DryRun, "dry-run", false, - "preview a destructive command's KAS request (action + redacted parameters) "+ + "preview a write command's KAS request (action + redacted parameters) "+ "and exit 0 without dispatching or prompting; honours --output") // --yes is wired: it is honoured by the destructive-write @@ -114,3 +129,27 @@ func NewRootCmd() (*cobra.Command, *RootOptions) { func joinFormats() string { return strings.Join(formatNames, "|") } + +// MarkArgErrorsAsUserErrors walks the command tree and wraps every +// positional-args validator (cobra.ExactArgs, cobra.NoArgs, ...) so a +// validation failure carries ExitUserError. Without it those errors +// surface raw from Execute and CodeFor maps them to the API-error exit +// 2, contradicting the documented "1 = user error" contract. Called by +// cmd/kasapi-cli after all subcommands are registered. +func MarkArgErrorsAsUserErrors(cmd *cobra.Command) { + if validate := cmd.Args; validate != nil { + cmd.Args = func(c *cobra.Command, args []string) error { + if err := validate(c, args); err != nil { + var ee *ExitError + if errors.As(err, &ee) { + return err + } + return UserError(err, "") + } + return nil + } + } + for _, sub := range cmd.Commands() { + MarkArgErrorsAsUserErrors(sub) + } +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 8643f9b..4e9e9ee 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -108,6 +108,50 @@ func TestRootRejectsUnknownFlag(t *testing.T) { } } +// TestRootUnknownCommandExitsUserError pins the exit-code contract for +// a mistyped subcommand: bad user input must map to exit 1, not fall +// through CodeFor to the API-error exit 2. +func TestRootUnknownCommandExitsUserError(t *testing.T) { + t.Parallel() + root, _ := cli.NewRootCmd() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"frobnicate"}) + err := root.Execute() + if err == nil { + t.Fatal("Execute frobnicate: want error, got nil") + } + if cli.CodeFor(err) != cli.ExitUserError { + t.Errorf("unknown command should map to ExitUserError, got %d", cli.CodeFor(err)) + } + if !strings.Contains(err.Error(), `unknown command "frobnicate"`) { + t.Errorf("error message: %q", err.Error()) + } +} + +// TestMarkArgErrorsAsUserErrors pins the exit-code contract for a +// positional-args validation failure (e.g. a missing required argument +// on an ExactArgs(1) subcommand) after the cmd/kasapi-cli wiring has +// applied the tree-wide wrapper. +func TestMarkArgErrorsAsUserErrors(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewDomainsCmd(opts)) + cli.MarkArgErrorsAsUserErrors(root) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"domains", "get"}) + err := root.Execute() + if err == nil { + t.Fatal("Execute domains get (missing arg): want error, got nil") + } + if cli.CodeFor(err) != cli.ExitUserError { + t.Errorf("missing positional arg should map to ExitUserError, got %d", cli.CodeFor(err)) + } +} + // TestConfigInitDoesNotShadowRootProfile guards against the local // --profile flag on `config init` reappearing and silently overriding // the persistent root --profile. The local flag is now --name; the diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1470624..ffeb72f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -168,6 +168,20 @@ func TestResolveUnknownProfile(t *testing.T) { } } +// TestResolveProfileWithoutConfigIsUnknownProfile pins the sentinel on +// the nil-Config + --profile path so callers can errors.Is-branch on it +// like on the profile-not-in-file case. +func TestResolveProfileWithoutConfigIsUnknownProfile(t *testing.T) { + var cfg *config.Config + _, err := cfg.Resolve(config.Env{}, config.Override{Profile: "prod"}) + if err == nil || !strings.Contains(err.Error(), "no config file loaded") { + t.Fatalf("expected no-config profile error, got %v", err) + } + if !errors.Is(err, config.ErrUnknownProfile) { + t.Errorf("err = %v, want errors.Is ErrUnknownProfile", err) + } +} + func TestResolveMissingCredentials(t *testing.T) { cfg := &config.Config{ Profiles: map[string]config.Profile{ diff --git a/internal/config/credentials.go b/internal/config/credentials.go index 35709ee..d9e3933 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -8,7 +8,8 @@ import ( ) // ErrUnknownProfile is returned by Resolve when the requested profile -// (via --profile or default_profile) does not exist in the config file. +// (via --profile or default_profile) does not exist in the config file, +// or when --profile was given without any config file loaded. // Callers branch with errors.Is instead of string-matching. var ErrUnknownProfile = errors.New("config: profile not defined") @@ -77,7 +78,7 @@ func (c *Config) Resolve(env Env, ov Override) (Credentials, error) { prof = p } } else if ov.Profile != "" { - return Credentials{}, fmt.Errorf("config: --profile %q given but no config file loaded", ov.Profile) + return Credentials{}, fmt.Errorf("%w: --profile %q given but no config file loaded", ErrUnknownProfile, ov.Profile) } cred := Credentials{ diff --git a/internal/cronjob/doc.go b/internal/cronjob/doc.go index bf02e97..2ffa4d1 100644 --- a/internal/cronjob/doc.go +++ b/internal/cronjob/doc.go @@ -1,4 +1,5 @@ // Package cronjob holds the domain types and use cases for the KAS cronjob -// endpoints (get_cronjobs, get_cronjob, add_cronjob, update_cronjob, -// delete_cronjob). See issues #11 and #13. +// endpoints (get_cronjobs, add_cronjob, update_cronjob, delete_cronjob). +// A single cronjob is a get_cronjobs call with the cronjob_id filter; +// there is no singular get action. See issues #11 and #13. package cronjob diff --git a/internal/cronjob/write_test.go b/internal/cronjob/write_test.go index d20380f..b74a14e 100644 --- a/internal/cronjob/write_test.go +++ b/internal/cronjob/write_test.go @@ -55,7 +55,7 @@ func TestClientAdd(t *testing.T) { // return the new id rather than wrapping ErrUnexpectedReturnString. func TestClientAddWarningStillSucceeds(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "cronjob/add_cronjob_response_warning.xml") + resp := testutil.DecodeFixture(t, "cronjob/add_cronjob_response_success_warning.xml") id, err := cronjob.NewClient(&testutil.FakeCaller{Resp: resp}).Add(context.Background(), sampleSpec()) if err != nil { t.Fatalf("Add (warning): %v", err) diff --git a/internal/ddns/ddns.go b/internal/ddns/ddns.go index 739a3b6..71915e4 100644 --- a/internal/ddns/ddns.go +++ b/internal/ddns/ddns.go @@ -33,8 +33,9 @@ type Caller = kasread.Caller // // `in_progress` is no longer omitempty for parity with the majority // of read modules (account, mailaccount, mailinglist, sambauser, -// ftpuser, database): every captured fixture row carries it and a -// stray empty-string on an older account is harmless. +// ftpuser, database). The captured ddns fixtures do not carry the key +// at all, so it decodes to an empty string — emitting that "" is +// harmless and matches what the other modules show on older accounts. type DDNSUser struct { Login string `json:"dyndns_login" yaml:"dyndns_login"` Password string `json:"dyndns_password,omitempty" yaml:"dyndns_password,omitempty"` diff --git a/internal/dns/dns_test.go b/internal/dns/dns_test.go index 79a5553..00df2de 100644 --- a/internal/dns/dns_test.go +++ b/internal/dns/dns_test.go @@ -75,7 +75,7 @@ func TestClientSettings(t *testing.T) { // zone_host+record_id fixture returns a one-element ReturnInfo array. func TestClientSettingsWithRecordID(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "dns/get_dns_settings_zone_host_and_record_id_response_success.xml") + resp := testutil.DecodeFixture(t, "dns/get_dns_settings_response_success_zone_host_and_record_id.xml") fc := &testutil.FakeCaller{Resp: resp} list, err := dns.NewClient(fc).Settings(context.Background(), "example.com", "110118416") if err != nil { diff --git a/internal/ftpuser/doc.go b/internal/ftpuser/doc.go index 3897b42..1490d68 100644 --- a/internal/ftpuser/doc.go +++ b/internal/ftpuser/doc.go @@ -1,7 +1,9 @@ // Package ftpuser holds the domain types and use cases for the KAS FTP-user -// endpoints (get_ftpusers, get_ftpuser, add_ftpuser, update_ftpuser, -// delete_ftpuser). See issues #11 and #13. +// endpoints (get_ftpusers, add_ftpuser, update_ftpuser, delete_ftpuser). +// A single user is a get_ftpusers call with the ftp_login filter; there +// is no singular get action. See issues #11 and #13. // -// Note: KAS docs spell the create action add_ftpusers (plural) while the -// fixture folder uses the singular form — verify against the live API. +// Note: KAS docs spell the create action add_ftpusers (plural); the +// captured request fixture (#119, verified against the live API) +// confirms the real action is the singular add_ftpuser. package ftpuser diff --git a/internal/mailaccount/doc.go b/internal/mailaccount/doc.go index edf53cf..08995ea 100644 --- a/internal/mailaccount/doc.go +++ b/internal/mailaccount/doc.go @@ -1,4 +1,6 @@ // Package mailaccount holds the domain types and use cases for the KAS -// mail-account endpoints (get_mailaccounts, get_mailaccount, add_mailaccount, -// update_mailaccount, delete_mailaccount). See issues #9 and #13. +// mail-account endpoints (get_mailaccounts, add_mailaccount, +// update_mailaccount, delete_mailaccount). A single account is a +// get_mailaccounts call with the mail_login filter; there is no +// singular get action. See issues #9 and #13. package mailaccount diff --git a/internal/session/store.go b/internal/session/store.go index 2989eb6..7683620 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -155,7 +155,10 @@ func (s *Store) Load(ctx context.Context, login string) (*Entry, error) { if !ok { return nil } - if !e.ExpiresAt.IsZero() && !s.now().Before(e.ExpiresAt) { + // A zero expires_at can only come from a hand-edited file — Save + // and Refresh always fill it. Treating it as never-expiring would + // make the token locally immortal, so it is expired instead. + if e.ExpiresAt.IsZero() || !s.now().Before(e.ExpiresAt) { return s.deleteLocked(login) } out = &e diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 4ec71f1..b750c53 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -1,6 +1,7 @@ package session_test import ( + "os" "path/filepath" "runtime" "testing" @@ -125,6 +126,29 @@ func TestLoadDropsExpiredEntry(t *testing.T) { } } +// TestLoadDropsZeroExpiryEntry pins that a hand-edited entry without +// expires_at (Save/Refresh always fill it) is treated as expired, not +// as never-expiring — otherwise the token would be locally immortal. +func TestLoadDropsZeroExpiryEntry(t *testing.T) { + now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) + path := filepath.Join(t.TempDir(), "sessions.toml") + if err := os.WriteFile(path, []byte("[sessions.w0]\ntoken = \"t\"\n"), 0o600); err != nil { + t.Fatalf("write sessions.toml: %v", err) + } + s, err := session.New(path) + if err != nil { + t.Fatalf("New: %v", err) + } + s.Now = func() time.Time { return now } + got, err := s.Load(t.Context(), "w0") + if err != nil { + t.Fatalf("Load: %v", err) + } + if got != nil { + t.Errorf("expected zero-expiry entry to be dropped, got %+v", got) + } +} + func TestDeleteRemovesEntryOnly(t *testing.T) { now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) s := newStore(t, now) diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index e01add1..1c079c1 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -64,12 +64,14 @@ func DecodeFixture(t *testing.T, relPath string) *soap.Response { // KAS contract: every testdata//*_response_failed_*.xml must // decode to a *soap.FaultError with a non-empty Fault.String, and each // entry in want (fixture filename -> expected fault code) must match -// exactly. It is the fixture<->contract anchor every module reuses so a -// captured fault fixture cannot silently drift from its documented KAS -// code. module is the testdata/ subdirectory (e.g. "ftpuser"); the -// empty string scans the shared top-level response_failed_*.xml set. -// want may be nil to assert only the universal invariant; pin a few -// representative documented codes there to also catch a code drift. +// exactly. A want key that matches no file on disk fails too, so a +// renamed or deleted fixture cannot leave a dead pin behind. It is the +// fixture<->contract anchor every module reuses so a captured fault +// fixture cannot silently drift from its documented KAS code. module +// is the testdata/ subdirectory (e.g. "ftpuser"); the empty string +// scans the shared top-level response_failed_*.xml set. want may be +// nil to assert only the universal invariant; pin a few representative +// documented codes there to also catch a code drift. func AssertFaultFixtures(t *testing.T, module string, want map[string]string) { t.Helper() dir := filepath.Join(RepoRoot(t), "testdata", module) @@ -78,6 +80,7 @@ func AssertFaultFixtures(t *testing.T, module string, want map[string]string) { t.Fatalf("read fixture dir %s: %v", dir, err) } seen := 0 + matched := map[string]bool{} for _, e := range entries { if e.IsDir() { continue @@ -90,6 +93,9 @@ func AssertFaultFixtures(t *testing.T, module string, want map[string]string) { continue } seen++ + if _, ok := want[name]; ok { + matched[name] = true + } //nolint:gosec // G304: fixture path is rooted at the repo testdata/ dir. f, oerr := os.Open(filepath.Join(dir, name)) if oerr != nil { @@ -112,6 +118,11 @@ func AssertFaultFixtures(t *testing.T, module string, want map[string]string) { if seen == 0 { t.Fatalf("no fault fixtures found for module %q", module) } + for name := range want { + if !matched[name] { + t.Errorf("want entry %s matches no fault fixture on disk (renamed or deleted?)", name) + } + } } // FakeCaller is a minimal stub for the Caller interface implemented by diff --git a/internal/transport/client.go b/internal/transport/client.go index c89b7c1..d90aa56 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -195,6 +195,16 @@ func (c *Client) doOnce(ctx context.Context, endpoint string, body []byte) ([]by } if resp.StatusCode >= 500 { + // A PHP SOAP server may deliver a SOAP fault with HTTP 500. Such + // a body must reach the decoder so the typed-fault path (auth + // refresh, flood fallback, exit-code classification) applies + // instead of three blind retries that discard the fault. The + // sniff matches any namespace prefix (SOAP-ENV:Fault, soap:Fault); + // only fault-free 5xx bodies (gateway/proxy errors) stay + // retryable. + if bytes.Contains(respBody, []byte(":Fault")) { + return respBody, nil + } return nil, &retryableError{err: fmt.Errorf("transport: %s returned %s", endpoint, resp.Status)} } if resp.StatusCode >= 400 { diff --git a/internal/transport/client_test.go b/internal/transport/client_test.go index 931fa1e..2c99e3b 100644 --- a/internal/transport/client_test.go +++ b/internal/transport/client_test.go @@ -169,6 +169,31 @@ func TestDoRetriesOn5xx(t *testing.T) { } } +func TestDoPassesThroughSoapFaultOn5xx(t *testing.T) { + const faultBody = `` + + `SOAP-ENV:Server` + + `flood_protection` + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, faultBody) + })) + defer srv.Close() + + c := newClient(srv, newFakeClock()) + body, err := c.Do(context.Background(), srv.URL, []byte(sampleEnvelope)) + if err != nil { + t.Fatalf("Do: %v", err) + } + if string(body) != faultBody { + t.Errorf("body = %q, want the fault body passed through", body) + } + if calls.Load() != 1 { + t.Errorf("calls = %d, want 1 (fault body must not be retried)", calls.Load()) + } +} + func TestDoStopsRetryAfterMax(t *testing.T) { var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/testdata/cronjob/add_cronjob_response_warning.xml b/testdata/cronjob/add_cronjob_response_success_warning.xml similarity index 100% rename from testdata/cronjob/add_cronjob_response_warning.xml rename to testdata/cronjob/add_cronjob_response_success_warning.xml diff --git a/testdata/dns/get_dns_settings_zone_host_and_record_id_request.xml b/testdata/dns/get_dns_settings_request_zone_host_and_record_id.xml similarity index 100% rename from testdata/dns/get_dns_settings_zone_host_and_record_id_request.xml rename to testdata/dns/get_dns_settings_request_zone_host_and_record_id.xml diff --git a/testdata/dns/get_dns_settings_zone_host_and_record_id_response_success.xml b/testdata/dns/get_dns_settings_response_success_zone_host_and_record_id.xml similarity index 100% rename from testdata/dns/get_dns_settings_zone_host_and_record_id_response_success.xml rename to testdata/dns/get_dns_settings_response_success_zone_host_and_record_id.xml diff --git a/testdata/domain/get_topleveldomains_request.xml b/testdata/domain/get_topleveldomains_request.xml index c6cfda6..ee944fc 100644 --- a/testdata/domain/get_topleveldomains_request.xml +++ b/testdata/domain/get_topleveldomains_request.xml @@ -5,7 +5,7 @@ { "KasRequestParams": {}, - "kas_action": "get_subdomains", + "kas_action": "get_topleveldomains", "kas_auth_data": "REDACTED", "kas_auth_type": "session", "kas_login": "w0000000" From 76a77b0d70c9d887dfb349613438b5d322bfe99b Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 12:47:19 +0200 Subject: [PATCH 13/16] docs: refresh README, ROADMAP, CLAUDE.md, and the destructive-writes contract to the shipped state README no longer claims write paths are pending and lists the full CI gate set; the destructive-writes prompt example matches the real single-line stderr prompt and the audit-trace scope excludes the sessions delete / config use-profile session logout explicitly; ROADMAP gains mail lists get and the real placeholder; CLAUDE.md's repository-state paragraph reflects the landed write slices. --- CLAUDE.md | 4 ++-- README.md | 6 +++--- ROADMAP.md | 4 ++-- docs/usage/destructive-writes.md | 14 +++++++++++--- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f87bbe4..c71dcb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Repository State -The read-phase modules are wired up (accounts, server, domains/subdomains/TLDs, DNS, mail, databases, FTP/Samba users, cronjobs, directory protection, software installs, DDNS users, usage statistics) and the standard CI gate (`lint & test` + `docs sync`) runs on every push and pull request. `main` is protected: signed commits are required, force-pushes to `main` are blocked for the GitHub UI / `gh pr merge --rebase` (signatures are stripped server-side), and merging happens via a locally-rebased fast-forward push by the maintainer (see `.claude/skills/kasapi-cli-git-workflow/SKILL.md`). +The read-phase modules are wired up (accounts, server, domains/subdomains/TLDs, DNS, mail, databases, FTP/Samba users, cronjobs, directory protection, software installs, DDNS users, usage statistics), and so are most v0.2.0 write slices (mail accounts/forwards/filters/lists, databases, FTP/Samba users, DDNS users, cronjobs, directory protection) including the destructive-write safety contract (confirmation gate, `--dry-run`, audit records — see `docs/usage/destructive-writes.md`). The CI gate (`lint & test`, `docs sync`, `goreleaser` config check, `govulncheck`, CodeQL) runs on every push and pull request. `main` is protected: signed commits are required, force-pushes to `main` are blocked for the GitHub UI / `gh pr merge --rebase` (signatures are stripped server-side), and merging happens via a locally-rebased fast-forward push by the maintainer (see `.claude/skills/kasapi-cli-git-workflow/SKILL.md`). -Write paths and the remaining read endpoints are part of the v0.2.0 backlog tracked on the *kasapi-cli v0.1.0* GitHub project; do not invent endpoints not documented at . +The remaining write endpoints (software installs, filesystem/SSL helpers) are part of the v0.2.0 backlog tracked on the *kasapi-cli v0.1.0* GitHub project; do not invent endpoints not documented at . There is no predecessor library and no inherited backlog. Do not assume or import patterns from any other KAS client; design from the KAS API docs and the fixtures in `testdata/`. diff --git a/README.md b/README.md index 6c02cdd..1f11205 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,13 @@ ## Status -Early development. The transport, authentication, configuration, and the v0.1.0 read modules are wired up; write paths and the remaining read endpoints are still pending — see [ROADMAP.md](ROADMAP.md) for the current state. The repository also ships recorded KAS-API response fixtures under `testdata/` that drive offline parser tests. +Early development. The transport, authentication, configuration, the v0.1.0 read modules, and most of the v0.2.0 write paths (mail accounts/forwards/filters/lists, databases, FTP/Samba users, DDNS users, cronjobs, directory protection) are wired up. The remaining write endpoints (software installs, the filesystem/SSL helpers) are still pending — see [ROADMAP.md](ROADMAP.md) for the current state. The repository also ships recorded KAS-API response fixtures under `testdata/` that drive offline parser tests. -CI gates `gofmt`/`go vet`/`golangci-lint` (with `gosec`)/`go test`/`go test -race`/`go build`, plus a `govulncheck` job on every PR. Dependabot keeps `gomod` and `github-actions` versions current. +CI gates `gofmt`/`go vet`/`golangci-lint` (with `gosec`)/`go test`/`go test -race`/`go build`, a docs-sync job (`make docs` must produce no diff against the checked-in `docs/cli/`), a `goreleaser` config check, CodeQL, plus a `govulncheck` job on every PR. Dependabot keeps `gomod` and `github-actions` versions current. ## What it does -`kasapi-cli` is a command-line client for the All-Inkl KAS-API. It wraps the SOAP/`ns2:Map` wire format the API uses, handles the `KasAuth` credential-token flow (plain or session, optional 2FA), enforces the `KasFloodDelay` between calls, and exposes read operations for the resources documented at . Write paths are scheduled for v0.2.0 — see [ROADMAP.md](ROADMAP.md). +`kasapi-cli` is a command-line client for the All-Inkl KAS-API. It wraps the SOAP/`ns2:Map` wire format the API uses, handles the `KasAuth` credential-token flow (plain or session, optional 2FA), enforces the `KasFloodDelay` between calls, and exposes read and write operations for the resources documented at . Destructive writes are gated behind a confirmation prompt, support `--dry-run`, and leave an audit trace — see [docs/usage/destructive-writes.md](docs/usage/destructive-writes.md). The remaining write endpoints are tracked in [ROADMAP.md](ROADMAP.md). ## Install diff --git a/ROADMAP.md b/ROADMAP.md index db6c5b6..3d5c916 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,7 +58,7 @@ Cross-cutting prerequisites for the v0.2.0 write phase — these are not KAS-API - [x] `mail forwards add/update/delete` (`add_mailforward`, `update_mailforward`, `delete_mailforward`, #115) - [x] `mail filters list` (`get_mailstandardfilter`) - [x] `mail filters add/delete` (`add_mailstandardfilter`, `delete_mailstandardfilter`, #116) -- [x] `mail lists list` (`get_mailinglists`) +- [x] `mail lists list` / `mail lists get ` (`get_mailinglists`) - [x] `mail lists add/update/delete` (`add_mailinglist`, `update_mailinglist`, `delete_mailinglist`, #117) ## Hosting resources @@ -69,7 +69,7 @@ Cross-cutting prerequisites for the v0.2.0 write phase — these are not KAS-API - [x] `ftpusers add/update/delete` (`add_ftpuser`, `update_ftpuser`, `delete_ftpuser`, #119) - [x] `sambausers list` / `sambausers get ` (`get_sambausers`, with `samba_login` filter) - [x] `sambausers add/update/delete` (`add_sambauser`, `update_sambauser`, `delete_sambauser`, #120) -- [x] `ddnsusers list` / `ddnsusers get ` (`get_ddnsusers`, with `ddns_login` filter) +- [x] `ddnsusers list` / `ddnsusers get ` (`get_ddnsusers`, with `ddns_login` filter) - [x] `ddnsusers add/update/delete` (`add_ddnsuser`, `update_ddnsuser`, `delete_ddnsuser`, #121) - [x] `cronjobs list` / `cronjobs get ` (`get_cronjobs`, with `cronjob_id` filter) - [x] `cronjobs add/update/delete` (`add_cronjob`, `update_cronjob`, `delete_cronjob`, #118) diff --git a/docs/usage/destructive-writes.md b/docs/usage/destructive-writes.md index 9d866a7..1595dd0 100644 --- a/docs/usage/destructive-writes.md +++ b/docs/usage/destructive-writes.md @@ -54,10 +54,12 @@ Before a destructive call leaves the machine, the command prints a one-line summary of the pending change and asks for confirmation: ``` -About to delete mail account "m0000001". This cannot be undone. -Proceed? [y/N]: +About to permanently delete mail account "m0000001". This cannot be undone. [y/N]: ``` +The prompt is written to **stderr**, so redirecting stdout (`cmd > +file`) cannot swallow the question the command is waiting on. + - Only an explicit `y` / `yes` (case-insensitive) proceeds. Empty input or anything else aborts. - Declining exits with code **1** (user error); nothing is sent to KAS. @@ -88,11 +90,17 @@ non-interactively. ## Audit log Independently of the confirmation prompt and of `--verbose`, every -dispatched write action leaves a structured trace +write action dispatched through a module write subcommand (the +`add`/`update`/`delete` slices listed above) leaves a structured trace ([#131](https://github.com/chmmou/kasapi-cli/issues/131)). The record is emitted **after** the SOAP call returns, regardless of success or failure. +The session logout dispatched by `sessions delete` and `config +use-profile` (`delete_session`) is not part of this pipeline: it only +invalidates the caller's own session token, is not gated, and leaves +no audit record. + A `logfmt`-style line always goes to **stderr**: ``` From e1552d99ce77a4a441478ca429b8b5d208e7a91a Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 12:47:19 +0200 Subject: [PATCH 14/16] docs(changelog): record the second-pass review fixes --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3973d9..47fb711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -575,6 +575,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Whole-codebase re-review follow-up (second pass, Med + Low findings): + - Bad user input now consistently exits 1: cobra positional-args + failures (e.g. a missing required argument) and an unknown root + subcommand were falling through to the API-error exit 2. The + command tree wraps every `Args` validator via + `cli.MarkArgErrorsAsUserErrors` and the root command classifies + unknown subcommands itself. + - `transport.Client.Do` no longer blindly retries a 5xx response + whose body carries a SOAP fault: the body is passed through to the + decoder so the typed-fault path (auth refresh, flood fallback, + exit-code classification) applies. Fault-free 5xx bodies stay + retryable. + - `testutil.AssertFaultFixtures` fails on `want` entries that match + no fixture file on disk, so a renamed or deleted fixture cannot + leave a dead pin behind; the shared top-level + `response_failed_*.xml` set is now anchored by a dedicated test in + `internal/api` pinning each fixture to its `api.Code*` constant. + - `config.Resolve` with a nil config and `--profile` wraps + `ErrUnknownProfile` instead of returning a string-only error. + - `session.Store.Load` treats an entry without `expires_at` (only + producible by hand-editing sessions.toml) as expired instead of + never-expiring. + - `cronjobs update` / `ftpusers update` bind their own flag sets + instead of sharing the add flags, so `--help` no longer advertises + add defaults (`https`, `*`, `default`) or "required for add" texts + — matching the database/mailaccount/ddnsuser/mailinglist split. + - The `--dry-run` help text says "write command" instead of + "destructive command" — the flag covers non-gated `add` writes too. + - `testdata/domain/get_topleveldomains_request.xml` carried + `kas_action: get_subdomains` (mis-captured copy); corrected to the + action the filename and the response fixture encode. + - Fixture names aligned with the documented convention: + `dns/get_dns_settings_{request,response_success}_zone_host_and_record_id.xml` + (variant after kind) and + `cronjob/add_cronjob_response_success_warning.xml` (a success + variant, not a distinct status). + - `internal/{ftpuser,cronjob,mailaccount}/doc.go` no longer name + non-existent singular get actions; the stale ftpuser "verify + against the live API" note now records the #119 verification + result. The `internal/ddns` `in_progress` comment no longer claims + fixture backing the captured fixtures do not contain. + - Docs refreshed to the shipped state: README status/CI/what-it-does + (write slices are live, not "pending"), the destructive-writes + prompt example (single line, `permanently delete` verb, stderr + note), the audit-trace scope (the `sessions delete` / `config + use-profile` session logout is explicitly outside the pipeline), + `ROADMAP.md` (`mail lists get`, `` placeholder), and + the CLAUDE.md repository-state paragraph. + - Whole-codebase review follow-up (Low findings): - `api.TokenSource.Invalidate` now reports whether the next Credentials call can produce fresh credentials; `api.Client` skips From 1c65eac61c04d2bfaff84f97e233db36a3f056f9 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 13:26:31 +0200 Subject: [PATCH 15/16] fix(cli,transport,api): address High/Med/Low findings from the third review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group commands invoked with an unknown subcommand exited 0 (help) — cli.Finalize now rejects them as user errors and registers the lazy completion command before the exit-code walkers, so its args errors exit 1 as well. Create actions with a server-generated identifier record it as created_id in the success audit record, a failed confirmation prompt read is audited as outcome=aborted, the transport 5xx fault sniff also recognises the prefix-less default-namespace Fault form (with an end-to-end api-layer HTTP-500 fault test), the sambausers update command binds its own replacement-flag set, and the remaining get_ fixtures were renamed to the plural KAS action their content embeds. --- cmd/kasapi-cli/main.go | 2 +- docs/cli/kasapi-cli_accounts.md | 4 + docs/cli/kasapi-cli_completion.md | 4 + docs/cli/kasapi-cli_config.md | 4 + docs/cli/kasapi-cli_cronjobs.md | 4 + docs/cli/kasapi-cli_databases.md | 4 + docs/cli/kasapi-cli_ddnsusers.md | 4 + docs/cli/kasapi-cli_directoryprotection.md | 4 + docs/cli/kasapi-cli_dns.md | 4 + docs/cli/kasapi-cli_domains.md | 4 + docs/cli/kasapi-cli_ftpusers.md | 4 + docs/cli/kasapi-cli_mail.md | 4 + docs/cli/kasapi-cli_mail_accounts.md | 4 + docs/cli/kasapi-cli_mail_filters.md | 4 + docs/cli/kasapi-cli_mail_forwards.md | 4 + docs/cli/kasapi-cli_mail_lists.md | 4 + docs/cli/kasapi-cli_sambausers.md | 4 + docs/cli/kasapi-cli_sambausers_add.md | 6 +- docs/cli/kasapi-cli_sambausers_update.md | 6 +- docs/cli/kasapi-cli_server.md | 4 + docs/cli/kasapi-cli_sessions.md | 4 + docs/cli/kasapi-cli_softwareinstalls.md | 4 + docs/cli/kasapi-cli_subdomains.md | 4 + docs/cli/kasapi-cli_tlds.md | 4 + docs/cli/kasapi-cli_usage.md | 4 + internal/api/client_test.go | 36 +++++++++ internal/cli/audit.go | 14 ++-- internal/cli/confirm.go | 9 ++- internal/cli/confirm_test.go | 17 ++++ internal/cli/cronjobs.go | 3 + internal/cli/databases.go | 3 + internal/cli/ddnsusers.go | 3 + internal/cli/ftpusers.go | 3 + internal/cli/mail.go | 9 +++ internal/cli/root.go | 56 ++++++++++--- internal/cli/root_test.go | 64 +++++++++++++++ internal/cli/run.go | 21 ++++- internal/cli/run_internal_test.go | 80 +++++++++++++++++++ internal/cli/sambausers.go | 43 +++++++--- internal/cronjob/cronjob_test.go | 6 +- internal/ddns/ddns_test.go | 8 +- internal/domain/domain_test.go | 6 +- internal/ftpuser/ftpuser_test.go | 10 +-- internal/mailaccount/mailaccount_test.go | 6 +- internal/mailforward/mailforward_test.go | 4 +- internal/mailinglist/mailinglist_test.go | 6 +- internal/sambauser/sambauser_test.go | 6 +- internal/transport/client.go | 12 ++- internal/transport/client_test.go | 25 ++++++ ...ml => get_cronjobs_request_cronjob_id.xml} | 0 ... get_cronjobs_response_success_single.xml} | 0 ...get_ddnsusers_response_success_single.xml} | 0 ...ml => get_domains_request_domain_name.xml} | 0 ...> get_domains_response_success_single.xml} | 0 ...xml => get_ftpusers_request_ftp_login.xml} | 0 ..._ftpusers_response_success_empty_list.xml} | 0 ... get_ftpusers_response_success_single.xml} | 0 ...> get_mailaccounts_request_mail_login.xml} | 0 ..._mailaccounts_response_success_single.xml} | 0 ...get_mailforwards_request_mail_forward.xml} | 0 ..._mailforwards_response_success_single.xml} | 0 ...mailinglists_request_mailinglist_name.xml} | 0 ..._mailinglists_response_success_single.xml} | 0 ...et_sambausers_response_success_single.xml} | 0 64 files changed, 484 insertions(+), 68 deletions(-) create mode 100644 internal/cli/run_internal_test.go rename testdata/cronjob/{get_cronjob_request.xml => get_cronjobs_request_cronjob_id.xml} (100%) rename testdata/cronjob/{get_cronjob_response_success.xml => get_cronjobs_response_success_single.xml} (100%) rename testdata/ddns/{get_ddnsuser_response_success.xml => get_ddnsusers_response_success_single.xml} (100%) rename testdata/domain/{get_domain_request.xml => get_domains_request_domain_name.xml} (100%) rename testdata/domain/{get_domain_response_success.xml => get_domains_response_success_single.xml} (100%) rename testdata/ftpuser/{get_ftpuser_request.xml => get_ftpusers_request_ftp_login.xml} (100%) rename testdata/ftpuser/{get_ftpuser_response_success_empty_list.xml => get_ftpusers_response_success_empty_list.xml} (100%) rename testdata/ftpuser/{get_ftpuser_response_success.xml => get_ftpusers_response_success_single.xml} (100%) rename testdata/mailaccount/{get_mailaccount_request.xml => get_mailaccounts_request_mail_login.xml} (100%) rename testdata/mailaccount/{get_mailaccount_response_success.xml => get_mailaccounts_response_success_single.xml} (100%) rename testdata/mailforward/{get_mailforward_request.xml => get_mailforwards_request_mail_forward.xml} (100%) rename testdata/mailforward/{get_mailforward_response_success.xml => get_mailforwards_response_success_single.xml} (100%) rename testdata/mailinglist/{get_mailinglist_request.xml => get_mailinglists_request_mailinglist_name.xml} (100%) rename testdata/mailinglist/{get_mailinglist_response_success.xml => get_mailinglists_response_success_single.xml} (100%) rename testdata/sambauser/{get_sambauser_response_success.xml => get_sambausers_response_success_single.xml} (100%) diff --git a/cmd/kasapi-cli/main.go b/cmd/kasapi-cli/main.go index 6c65e96..66db5e4 100644 --- a/cmd/kasapi-cli/main.go +++ b/cmd/kasapi-cli/main.go @@ -34,7 +34,7 @@ func main() { cli.NewConfigCmd(opts), cli.NewGenDocsCmd(), ) - cli.MarkArgErrorsAsUserErrors(root) + cli.Finalize(root) if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, "kasapi-cli:", err) os.Exit(cli.CodeFor(err)) diff --git a/docs/cli/kasapi-cli_accounts.md b/docs/cli/kasapi-cli_accounts.md index 06ec9be..177fbdb 100644 --- a/docs/cli/kasapi-cli_accounts.md +++ b/docs/cli/kasapi-cli_accounts.md @@ -2,6 +2,10 @@ Inspect KAS accounts owned by the authenticated login +``` +kasapi-cli accounts [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_completion.md b/docs/cli/kasapi-cli_completion.md index 13b5612..0cd88ad 100644 --- a/docs/cli/kasapi-cli_completion.md +++ b/docs/cli/kasapi-cli_completion.md @@ -8,6 +8,10 @@ Generate the autocompletion script for kasapi-cli for the specified shell. See each sub-command's help for details on how to use the generated script. +``` +kasapi-cli completion [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_config.md b/docs/cli/kasapi-cli_config.md index 450d221..9769796 100644 --- a/docs/cli/kasapi-cli_config.md +++ b/docs/cli/kasapi-cli_config.md @@ -2,6 +2,10 @@ Inspect and bootstrap the kasapi-cli configuration +``` +kasapi-cli config [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_cronjobs.md b/docs/cli/kasapi-cli_cronjobs.md index 89d72e3..be5aa95 100644 --- a/docs/cli/kasapi-cli_cronjobs.md +++ b/docs/cli/kasapi-cli_cronjobs.md @@ -2,6 +2,10 @@ Inspect and manage cronjobs (get/add/update/delete_cronjob) +``` +kasapi-cli cronjobs [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_databases.md b/docs/cli/kasapi-cli_databases.md index 7738e96..ef2a1e4 100644 --- a/docs/cli/kasapi-cli_databases.md +++ b/docs/cli/kasapi-cli_databases.md @@ -2,6 +2,10 @@ Inspect and manage databases (get/add/update/delete_database) +``` +kasapi-cli databases [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_ddnsusers.md b/docs/cli/kasapi-cli_ddnsusers.md index 1fe6be4..e9fdfff 100644 --- a/docs/cli/kasapi-cli_ddnsusers.md +++ b/docs/cli/kasapi-cli_ddnsusers.md @@ -2,6 +2,10 @@ Inspect and manage DDNS users (get/add/update/delete_ddnsuser) +``` +kasapi-cli ddnsusers [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_directoryprotection.md b/docs/cli/kasapi-cli_directoryprotection.md index 32d04ba..1bc3fd7 100644 --- a/docs/cli/kasapi-cli_directoryprotection.md +++ b/docs/cli/kasapi-cli_directoryprotection.md @@ -2,6 +2,10 @@ Inspect and manage directory (htaccess) protections (get/add/update/delete_directoryprotection) +``` +kasapi-cli directoryprotection [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_dns.md b/docs/cli/kasapi-cli_dns.md index cc9b90d..69a37ab 100644 --- a/docs/cli/kasapi-cli_dns.md +++ b/docs/cli/kasapi-cli_dns.md @@ -2,6 +2,10 @@ Inspect DNS records for a zone +``` +kasapi-cli dns [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_domains.md b/docs/cli/kasapi-cli_domains.md index ee958c7..c63926d 100644 --- a/docs/cli/kasapi-cli_domains.md +++ b/docs/cli/kasapi-cli_domains.md @@ -2,6 +2,10 @@ Inspect domains owned by the authenticated account +``` +kasapi-cli domains [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_ftpusers.md b/docs/cli/kasapi-cli_ftpusers.md index 992bd10..7db7b1d 100644 --- a/docs/cli/kasapi-cli_ftpusers.md +++ b/docs/cli/kasapi-cli_ftpusers.md @@ -2,6 +2,10 @@ Inspect and manage FTP users (get/add/update/delete_ftpuser) +``` +kasapi-cli ftpusers [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_mail.md b/docs/cli/kasapi-cli_mail.md index b9d258a..3ef6910 100644 --- a/docs/cli/kasapi-cli_mail.md +++ b/docs/cli/kasapi-cli_mail.md @@ -2,6 +2,10 @@ Inspect mail accounts and filters; inspect and manage forwards and mailing lists +``` +kasapi-cli mail [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_mail_accounts.md b/docs/cli/kasapi-cli_mail_accounts.md index 3452bbb..7d5503b 100644 --- a/docs/cli/kasapi-cli_mail_accounts.md +++ b/docs/cli/kasapi-cli_mail_accounts.md @@ -2,6 +2,10 @@ Inspect and manage mail accounts (get/add/update/delete_mailaccount) +``` +kasapi-cli mail accounts [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_mail_filters.md b/docs/cli/kasapi-cli_mail_filters.md index 42c5dbf..9baa94c 100644 --- a/docs/cli/kasapi-cli_mail_filters.md +++ b/docs/cli/kasapi-cli_mail_filters.md @@ -2,6 +2,10 @@ Inspect and manage mail standard filters (get/add/delete_mailstandardfilter) +``` +kasapi-cli mail filters [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_mail_forwards.md b/docs/cli/kasapi-cli_mail_forwards.md index 8946bf4..292e3c5 100644 --- a/docs/cli/kasapi-cli_mail_forwards.md +++ b/docs/cli/kasapi-cli_mail_forwards.md @@ -2,6 +2,10 @@ Inspect and manage mail forwards (get/add/update/delete_mailforward) +``` +kasapi-cli mail forwards [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_mail_lists.md b/docs/cli/kasapi-cli_mail_lists.md index 6c9f89a..8a6ed75 100644 --- a/docs/cli/kasapi-cli_mail_lists.md +++ b/docs/cli/kasapi-cli_mail_lists.md @@ -2,6 +2,10 @@ Inspect and manage mailing lists (get/add/update/delete_mailinglist) +``` +kasapi-cli mail lists [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_sambausers.md b/docs/cli/kasapi-cli_sambausers.md index dce1159..e0973c7 100644 --- a/docs/cli/kasapi-cli_sambausers.md +++ b/docs/cli/kasapi-cli_sambausers.md @@ -2,6 +2,10 @@ Inspect and manage Samba/CIFS users (get/add/update/delete_sambauser) +``` +kasapi-cli sambausers [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_sambausers_add.md b/docs/cli/kasapi-cli_sambausers_add.md index f421ae3..852b685 100644 --- a/docs/cli/kasapi-cli_sambausers_add.md +++ b/docs/cli/kasapi-cli_sambausers_add.md @@ -9,10 +9,10 @@ kasapi-cli sambausers add --password --comment --path

[flags] ### Options ``` - --comment string user comment / label (required for add) + --comment string user comment / label (required) -h, --help help for add - --password string Samba password (required for add; new password for update) - --path string share path the user is granted (samba_path; required for add) + --password string Samba password (required) + --path string share path the user is granted (samba_path; required) ``` ### Options inherited from parent commands diff --git a/docs/cli/kasapi-cli_sambausers_update.md b/docs/cli/kasapi-cli_sambausers_update.md index c654fb3..0fef04a 100644 --- a/docs/cli/kasapi-cli_sambausers_update.md +++ b/docs/cli/kasapi-cli_sambausers_update.md @@ -9,10 +9,10 @@ kasapi-cli sambausers update [password/path flags] [flags] ### Options ``` - --comment string user comment / label (required for add) + --comment string replacement user comment / label -h, --help help for update - --password string Samba password (required for add; new password for update) - --path string share path the user is granted (samba_path; required for add) + --password string replacement Samba password (sent as samba_new_password) + --path string replacement share path the user is granted (samba_path) ``` ### Options inherited from parent commands diff --git a/docs/cli/kasapi-cli_server.md b/docs/cli/kasapi-cli_server.md index 634a532..776ce53 100644 --- a/docs/cli/kasapi-cli_server.md +++ b/docs/cli/kasapi-cli_server.md @@ -2,6 +2,10 @@ Inspect the host server kasapi-cli is talking to +``` +kasapi-cli server [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_sessions.md b/docs/cli/kasapi-cli_sessions.md index c9cc397..0909328 100644 --- a/docs/cli/kasapi-cli_sessions.md +++ b/docs/cli/kasapi-cli_sessions.md @@ -8,6 +8,10 @@ Manage KAS session tokens. add_session is not a separate endpoint — it is the KasAuth credential-token flow driven transparently by auth_type=session (see `config init`). Only delete_session is exposed here, as the explicit counterpart to the implicit logout in `config use-profile`. +``` +kasapi-cli sessions [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_softwareinstalls.md b/docs/cli/kasapi-cli_softwareinstalls.md index 085c8b5..b59fe7b 100644 --- a/docs/cli/kasapi-cli_softwareinstalls.md +++ b/docs/cli/kasapi-cli_softwareinstalls.md @@ -2,6 +2,10 @@ Inspect installable software templates (get_softwareinstall) +``` +kasapi-cli softwareinstalls [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_subdomains.md b/docs/cli/kasapi-cli_subdomains.md index 93a2bf7..7de14b7 100644 --- a/docs/cli/kasapi-cli_subdomains.md +++ b/docs/cli/kasapi-cli_subdomains.md @@ -2,6 +2,10 @@ Inspect subdomains owned by the authenticated account +``` +kasapi-cli subdomains [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_tlds.md b/docs/cli/kasapi-cli_tlds.md index a4e210b..6efc4d2 100644 --- a/docs/cli/kasapi-cli_tlds.md +++ b/docs/cli/kasapi-cli_tlds.md @@ -2,6 +2,10 @@ Inspect the catalog of registrable top-level domains +``` +kasapi-cli tlds [flags] +``` + ### Options ``` diff --git a/docs/cli/kasapi-cli_usage.md b/docs/cli/kasapi-cli_usage.md index bd829b4..292bc19 100644 --- a/docs/cli/kasapi-cli_usage.md +++ b/docs/cli/kasapi-cli_usage.md @@ -2,6 +2,10 @@ Inspect webspace and traffic counters +``` +kasapi-cli usage [flags] +``` + ### Options ``` diff --git a/internal/api/client_test.go b/internal/api/client_test.go index a62d4c9..972fa3a 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -142,6 +142,42 @@ func TestCallReturnsTypedFault(t *testing.T) { } } +// TestCallReturnsTypedFaultOnHTTP500 pins the end-to-end contract +// behind the transport 5xx fault pass-through: a KAS fault delivered +// with HTTP 500 (PHP SOAP servers do this) must reach the decoder and +// surface as the same typed *api.Error a 200-wrapped fault produces, +// instead of being burned in blind transport retries. +func TestCallReturnsTypedFaultOnHTTP500(t *testing.T) { + body := loadFixture(t, "account/add_account_response_failed_max_account_reached.xml") + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write(body) + })) + defer srv.Close() + + tr := transport.New() + tr.HTTPClient = srv.Client() + tr.MaxRetries = 2 + tr.Now = time.Now + tr.Sleep = func(_ context.Context, _ time.Duration) error { return nil } + c := api.New(tr, staticTokens()) + c.Endpoint = srv.URL + + _, err := c.Call(context.Background(), "add_account", nil) + apiErr := api.AsError(err) + if apiErr == nil { + t.Fatalf("expected *api.Error, got %T: %v", err, err) + } + if apiErr.Code != "max_account_reached" { + t.Errorf("Code = %q, want max_account_reached", apiErr.Code) + } + if calls.Load() != 1 { + t.Errorf("calls = %d, want 1 (500-wrapped fault must not be retried)", calls.Load()) + } +} + func TestCallRetriesOnAuthFailure(t *testing.T) { authBody := loadFixture(t, "response_failed_no_auth.xml") okBody := loadFixture(t, "account/get_accounts_response_success.xml") diff --git a/internal/cli/audit.go b/internal/cli/audit.go index 192744c..d201f0f 100644 --- a/internal/cli/audit.go +++ b/internal/cli/audit.go @@ -33,15 +33,17 @@ type AuditRecord struct { // from a real success or failure. const AuditOutcomeDryRun = "dry-run" -// AuditOutcomeDeclined and AuditOutcomeRefused are the Outcome values -// for destructive attempts that never dispatched: "declined" when the -// user answered the [y/N] prompt with no, "refused" when stdin was not -// a TTY and --yes was not given. Auditing the attempt keeps the trace -// complete — a blocked destructive action is still an action someone -// tried to run. +// AuditOutcomeDeclined, AuditOutcomeRefused and AuditOutcomeAborted are +// the Outcome values for destructive attempts that never dispatched: +// "declined" when the user answered the [y/N] prompt with no, "refused" +// when stdin was not a TTY and --yes was not given, "aborted" when the +// interactive prompt failed with an I/O error before an answer was +// read. Auditing the attempt keeps the trace complete — a blocked +// destructive action is still an action someone tried to run. const ( AuditOutcomeDeclined = "declined" AuditOutcomeRefused = "refused" + AuditOutcomeAborted = "aborted" ) // OutcomeFor maps a write call's error to the audit outcome string: diff --git a/internal/cli/confirm.go b/internal/cli/confirm.go index ab15e5f..5c08daa 100644 --- a/internal/cli/confirm.go +++ b/internal/cli/confirm.go @@ -56,6 +56,13 @@ var ErrConfirmationDeclined = errors.New("cli: destructive operation cancelled b // interactively or pass --yes to proceed non-interactively. var ErrConfirmationRequired = errors.New("cli: refusing destructive operation: stdin is not a TTY and --yes was not given") +// ErrConfirmationAborted is returned by GateDestructive when the +// interactive [y/N] prompt failed with an I/O error before an answer +// was read. Like a decline it means the destructive attempt never +// dispatched, so the write runner still records it in the audit trace +// (outcome "aborted"). +var ErrConfirmationAborted = errors.New("cli: destructive operation aborted: confirmation prompt failed") + // ConfirmAction is the one-line description of a pending destructive // change, shown to the user before the [y/N] prompt: "About to // \"\". This cannot be undone." @@ -93,7 +100,7 @@ func GateDestructive(in io.Reader, out io.Writer, isTTY, yes bool, a ConfirmActi } ok, err := Confirm(in, out, a.Summary()) if err != nil { - return UserError(err, "confirm") + return UserError(fmt.Errorf("%w: %w", ErrConfirmationAborted, err), "") } if !ok { return UserError(ErrConfirmationDeclined, "") diff --git a/internal/cli/confirm_test.go b/internal/cli/confirm_test.go index 915b02e..db42564 100644 --- a/internal/cli/confirm_test.go +++ b/internal/cli/confirm_test.go @@ -5,6 +5,7 @@ import ( "errors" "strings" "testing" + "testing/iotest" "github.com/chmmou/kasapi-cli/internal/cli" ) @@ -84,3 +85,19 @@ func TestGateDestructive(t *testing.T) { }) } } + +// A prompt I/O failure (neither a yes nor a no was read) is an aborted +// attempt: ErrConfirmationAborted, exit 1, so the write runner can +// audit it as outcome=aborted. +func TestGateDestructiveAbortsOnPromptIOError(t *testing.T) { + t.Parallel() + var out bytes.Buffer + action := cli.ConfirmAction{Verb: "delete", Resource: "mail account", ID: "m0000001"} + err := cli.GateDestructive(iotest.ErrReader(errors.New("tty gone")), &out, true, false, action) + if !errors.Is(err, cli.ErrConfirmationAborted) { + t.Errorf("err = %v, want wrapped ErrConfirmationAborted", err) + } + if got := cli.CodeFor(err); got != cli.ExitUserError { + t.Errorf("CodeFor = %d, want ExitUserError (%d)", got, cli.ExitUserError) + } +} diff --git a/internal/cli/cronjobs.go b/internal/cli/cronjobs.go index 317c2e6..026e7d4 100644 --- a/internal/cli/cronjobs.go +++ b/internal/cli/cronjobs.go @@ -133,16 +133,19 @@ func newCronjobsAddCmd(opts *RootOptions) *cobra.Command { return writeSpec{}, fmt.Errorf("--minute and --hour are required") } s := f.spec() + var createdID string return writeSpec{ action: "add_cronjob", destructive: false, confirm: ConfirmAction{Verb: "create", Resource: "cronjob", ID: f.comment}, params: cronjob.AddParams(s), + createdID: &createdID, dispatch: func(c *api.Client, ctx context.Context) (string, error) { id, derr := cronjob.NewClient(c).Add(ctx, s) if derr != nil { return "", derr } + createdID = id return "created cronjob " + id, nil }, }, nil diff --git a/internal/cli/databases.go b/internal/cli/databases.go index 6f613b1..424edb0 100644 --- a/internal/cli/databases.go +++ b/internal/cli/databases.go @@ -108,16 +108,19 @@ access.`, return writeSpec{}, fmt.Errorf("--comment is required") } s := f.spec() + var createdID string return writeSpec{ action: "add_database", destructive: false, confirm: ConfirmAction{Verb: "create", Resource: "database", ID: f.comment}, params: database.AddParams(s), + createdID: &createdID, dispatch: func(c *api.Client, ctx context.Context) (string, error) { login, derr := database.NewClient(c).Add(ctx, s) if derr != nil { return "", derr } + createdID = login return "created database " + login, nil }, }, nil diff --git a/internal/cli/ddnsusers.go b/internal/cli/ddnsusers.go index 5e73c65..3adcc69 100644 --- a/internal/cli/ddnsusers.go +++ b/internal/cli/ddnsusers.go @@ -130,16 +130,19 @@ flag) so the follow-up update can set both ipv4 and ipv6 targets — see return writeSpec{}, fmt.Errorf("--comment is required") } s := f.spec() + var createdID string return writeSpec{ action: "add_ddnsuser", destructive: false, confirm: ConfirmAction{Verb: "create", Resource: "ddns user", ID: f.comment}, params: ddns.AddParams(s), + createdID: &createdID, dispatch: func(c *api.Client, ctx context.Context) (string, error) { login, derr := ddns.NewClient(c).Add(ctx, s) if derr != nil { return "", derr } + createdID = login return "created ddns user " + login, nil }, }, nil diff --git a/internal/cli/ftpusers.go b/internal/cli/ftpusers.go index 7421b83..5d010d3 100644 --- a/internal/cli/ftpusers.go +++ b/internal/cli/ftpusers.go @@ -103,16 +103,19 @@ func newFTPUsersAddCmd(opts *RootOptions) *cobra.Command { return writeSpec{}, fmt.Errorf("--comment is required") } s := f.spec() + var createdID string return writeSpec{ action: "add_ftpuser", destructive: false, confirm: ConfirmAction{Verb: "create", Resource: "ftp user", ID: f.comment}, params: ftpuser.AddParams(s), + createdID: &createdID, dispatch: func(c *api.Client, ctx context.Context) (string, error) { login, derr := ftpuser.NewClient(c).Add(ctx, s) if derr != nil { return "", derr } + createdID = login return "created ftp user " + login, nil }, }, nil diff --git a/internal/cli/mail.go b/internal/cli/mail.go index ba5fcb8..f1a7374 100644 --- a/internal/cli/mail.go +++ b/internal/cli/mail.go @@ -62,16 +62,19 @@ func newMailListsAddCmd(opts *RootOptions) *cobra.Command { if password == "" { return writeSpec{}, fmt.Errorf("--password is required") } + var createdID string return writeSpec{ action: "add_mailinglist", destructive: false, confirm: ConfirmAction{Verb: "create", Resource: "mailing list", ID: name}, params: mailinglist.AddParams(name, domain, password), + createdID: &createdID, dispatch: func(c *api.Client, ctx context.Context) (string, error) { id, derr := mailinglist.NewClient(c).Add(ctx, name, domain, password) if derr != nil { return "", derr } + createdID = id return "created mailing list " + id, nil }, }, nil @@ -411,16 +414,19 @@ complete create; override any field with its flag.`, return writeSpec{}, fmt.Errorf("--password is required") } s := f.spec(local, domain) + var createdID string return writeSpec{ action: "add_mailaccount", destructive: false, confirm: ConfirmAction{Verb: "create", Resource: "mail account", ID: args[0]}, params: mailaccount.AddParams(s), + createdID: &createdID, dispatch: func(c *api.Client, ctx context.Context) (string, error) { login, derr := mailaccount.NewClient(c).Add(ctx, s) if derr != nil { return "", derr } + createdID = login return "created mail account " + login, nil }, }, nil @@ -619,16 +625,19 @@ func newMailForwardsAddCmd(opts *RootOptions) *cobra.Command { if len(targets) == 0 { return writeSpec{}, fmt.Errorf("at least one --target is required") } + var createdID string return writeSpec{ action: "add_mailforward", destructive: false, confirm: ConfirmAction{Verb: "create", Resource: "mail forward", ID: args[0]}, params: mailforward.AddParams(local, domain, targets), + createdID: &createdID, dispatch: func(c *api.Client, ctx context.Context) (string, error) { addr, derr := mailforward.NewClient(c).Add(ctx, local, domain, targets) if derr != nil { return "", derr } + createdID = addr return "created mail forward " + addr, nil }, }, nil diff --git a/internal/cli/root.go b/internal/cli/root.go index 73be270..6713049 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -67,16 +67,7 @@ func NewRootCmd() (*cobra.Command, *RootOptions) { // rejection, but as a UserError so `kasapi-cli nonsense` exits 1 // (bad user input) instead of falling through CodeFor to the // API-error exit 2. - Args: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return nil - } - msg := fmt.Sprintf("unknown command %q for %q", args[0], cmd.CommandPath()) - if s := cmd.SuggestionsFor(args[0]); len(s) > 0 { - msg += fmt.Sprintf("\n\nDid you mean this?\n\t%s\n", strings.Join(s, "\n\t")) - } - return UserError(errors.New(msg), "") - }, + Args: unknownSubcommandArgs, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, @@ -130,6 +121,51 @@ func joinFormats() string { return strings.Join(formatNames, "|") } +// unknownSubcommandArgs is the positional-args validator shared by the +// root command and every group command: any positional argument is a +// subcommand name that did not resolve, so it is rejected as a +// UserError (exit 1) with cobra's own "Did you mean this?" suggestions. +func unknownSubcommandArgs(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + msg := fmt.Sprintf("unknown command %q for %q", args[0], cmd.CommandPath()) + if s := cmd.SuggestionsFor(args[0]); len(s) > 0 { + msg += fmt.Sprintf("\n\nDid you mean this?\n\t%s\n", strings.Join(s, "\n\t")) + } + return UserError(errors.New(msg), "") +} + +// Finalize prepares the fully-assembled command tree for Execute. It +// registers cobra's lazily-added completion command up front (so the +// walkers below see it), rejects unknown subcommands on group commands, +// and marks args-validation failures as user errors. Lives in cli, not +// cmd/kasapi-cli, so tests exercise exactly the wiring the binary runs. +func Finalize(root *cobra.Command) { + root.InitDefaultCompletionCmd() + RejectUnknownSubcommands(root) + MarkArgErrorsAsUserErrors(root) +} + +// RejectUnknownSubcommands walks the command tree and gives every +// non-runnable group command (mail, accounts, config, ...) an explicit +// unknown-subcommand rejection. Without it cobra treats a group invoked +// with an unresolved name as a bare help call and exits 0 — a typo'd +// subcommand would read as success to scripts (cobra's legacyArgs only +// rejects unknown names at the root). A bare group invocation keeps +// printing help and exiting 0, matching the root command's behaviour. +func RejectUnknownSubcommands(cmd *cobra.Command) { + if cmd.HasSubCommands() && !cmd.Runnable() { + cmd.Args = unknownSubcommandArgs + cmd.RunE = func(c *cobra.Command, _ []string) error { + return c.Help() + } + } + for _, sub := range cmd.Commands() { + RejectUnknownSubcommands(sub) + } +} + // MarkArgErrorsAsUserErrors walks the command tree and wraps every // positional-args validator (cobra.ExactArgs, cobra.NoArgs, ...) so a // validation failure carries ExitUserError. Without it those errors diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 4e9e9ee..350e4a6 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -130,6 +130,70 @@ func TestRootUnknownCommandExitsUserError(t *testing.T) { } } +// TestGroupUnknownSubcommandExitsUserError pins the exit-code contract +// for a typo'd subcommand under a group command: cobra alone treats it +// as a bare help call and exits 0, so Finalize must reject it as a +// user error (exit 1). +func TestGroupUnknownSubcommandExitsUserError(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewMailCmd(opts)) + cli.Finalize(root) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"mail", "frobnicate"}) + err := root.Execute() + if err == nil { + t.Fatal("Execute mail frobnicate: want error, got nil") + } + if cli.CodeFor(err) != cli.ExitUserError { + t.Errorf("unknown subcommand should map to ExitUserError, got %d", cli.CodeFor(err)) + } + if !strings.Contains(err.Error(), `unknown command "frobnicate"`) { + t.Errorf("error message: %q", err.Error()) + } +} + +// A bare group invocation stays a help call with exit 0 after Finalize. +func TestGroupBareInvocationPrintsHelp(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewMailCmd(opts)) + cli.Finalize(root) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"mail"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute mail: %v", err) + } + if !strings.Contains(out.String(), "accounts") { + t.Errorf("group help output missing subcommands:\n%s", out.String()) + } +} + +// TestCompletionArgErrorsExitUserError pins that the lazily-registered +// completion command is covered too: Finalize registers it before the +// walkers run, so its args-validation failures exit 1, not 2. +func TestCompletionArgErrorsExitUserError(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewDomainsCmd(opts)) + cli.Finalize(root) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"completion", "bash", "extra"}) + err := root.Execute() + if err == nil { + t.Fatal("Execute completion bash extra: want error, got nil") + } + if cli.CodeFor(err) != cli.ExitUserError { + t.Errorf("completion args error should map to ExitUserError, got %d", cli.CodeFor(err)) + } +} + // TestMarkArgErrorsAsUserErrors pins the exit-code contract for a // positional-args validation failure (e.g. a missing required argument // on an ExactArgs(1) subcommand) after the cmd/kasapi-cli wiring has diff --git a/internal/cli/run.go b/internal/cli/run.go index 27c7937..35cc27b 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -88,6 +88,14 @@ type writeSpec struct { confirm ConfirmAction params map[string]any dispatch func(c *api.Client, ctx context.Context) (string, error) + + // createdID, when non-nil, points at a variable the dispatch closure + // fills with the server-generated identifier of a create action + // (add_ftpuser, add_database, ...). A non-empty value is added to + // the success audit record's Fields under "created_id" so the trace + // correlates the create with the identifier later update/delete + // records carry as their Target. Nil for every other action. + createdID *string } // runWriteE is the write-subcommand counterpart of runListE/runGetE and @@ -171,6 +179,12 @@ func runWriteE(opts *RootOptions, build func(args []string) (writeSpec, error)) Outcome: OutcomeFor(derr), Fields: RedactParams(spec.params), } + if derr == nil && spec.createdID != nil && *spec.createdID != "" { + if rec.Fields == nil { + rec.Fields = map[string]string{} + } + rec.Fields["created_id"] = *spec.createdID + } werr := WriteAudit(stderr, auditFile, rec) if derr != nil { // The dispatch outcome outranks an audit-write failure: a KAS @@ -199,14 +213,17 @@ func runWriteE(opts *RootOptions, build func(args []string) (writeSpec, error)) // refusalOutcome maps a WriteResolver refusal error to its audit // outcome: "declined" for an interactive no, "refused" for the -// non-TTY-without---yes abort. Any other error (or nil, the --dry-run -// case, which writes its own record) yields "" — no record. +// non-TTY-without---yes abort, "aborted" for a prompt I/O failure. +// Any other error (or nil, the --dry-run case, which writes its own +// record) yields "" — no record. func refusalOutcome(err error) string { switch { case errors.Is(err, ErrConfirmationDeclined): return AuditOutcomeDeclined case errors.Is(err, ErrConfirmationRequired): return AuditOutcomeRefused + case errors.Is(err, ErrConfirmationAborted): + return AuditOutcomeAborted default: return "" } diff --git a/internal/cli/run_internal_test.go b/internal/cli/run_internal_test.go new file mode 100644 index 0000000..7c7479b --- /dev/null +++ b/internal/cli/run_internal_test.go @@ -0,0 +1,80 @@ +package cli + +// White-box tests for the runWriteE seam: the success-audit created_id +// correlation and the refusal→outcome mapping are internal wiring the +// cli_test package cannot reach (writeSpec and refusalOutcome are +// unexported), so they are pinned here. + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/chmmou/kasapi-cli/internal/api" +) + +// TestRunWriteESuccessAuditCarriesCreatedID pins that a create action +// whose identifier KAS generates server-side surfaces that identifier +// in the success audit record (created_id), not only in the rendered +// success line. +func TestRunWriteESuccessAuditCarriesCreatedID(t *testing.T) { + t.Parallel() + opts := &RootOptions{ + Login: "w0000000", + AuthData: "x", + AuthType: "plain", + Output: FormatTable, + } + var createdID string + run := runWriteE(opts, func([]string) (writeSpec, error) { + return writeSpec{ + action: "add_ftpuser", + destructive: false, + confirm: ConfirmAction{Verb: "create", Resource: "ftp user", ID: "backup user"}, + params: map[string]any{"ftp_comment": "backup user"}, + createdID: &createdID, + dispatch: func(_ *api.Client, _ context.Context) (string, error) { + createdID = "f0000001" + return "created ftp user f0000001", nil + }, + }, nil + }) + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + var out, errb bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errb) + if err := run(cmd, nil); err != nil { + t.Fatalf("runWriteE: %v", err) + } + if !strings.Contains(errb.String(), "created_id=f0000001") { + t.Errorf("audit line missing created_id=f0000001: %s", errb.String()) + } + if !strings.Contains(errb.String(), "outcome=success") { + t.Errorf("audit line missing outcome=success: %s", errb.String()) + } +} + +func TestRefusalOutcome(t *testing.T) { + t.Parallel() + cases := []struct { + err error + want string + }{ + {UserError(ErrConfirmationDeclined, ""), AuditOutcomeDeclined}, + {UserError(ErrConfirmationRequired, ""), AuditOutcomeRefused}, + {UserError(fmt.Errorf("%w: %w", ErrConfirmationAborted, errors.New("tty gone")), ""), AuditOutcomeAborted}, + {errors.New("unrelated"), ""}, + {nil, ""}, + } + for _, c := range cases { + if got := refusalOutcome(c.err); got != c.want { + t.Errorf("refusalOutcome(%v) = %q, want %q", c.err, got, c.want) + } + } +} diff --git a/internal/cli/sambausers.go b/internal/cli/sambausers.go index e812ce9..31c54b8 100644 --- a/internal/cli/sambausers.go +++ b/internal/cli/sambausers.go @@ -52,13 +52,12 @@ func newSambaUsersGetCmd(opts *RootOptions) *cobra.Command { } } -// sambauserWriteFlags binds the shared add_sambauser / update_sambauser -// request fields to a command. The same flag set serves both: add -// reads every value, update sends only the flags the user explicitly -// changed (see sambauserChangedFields). The password flag maps to a -// different KAS key per action (add_sambauser: samba_password, -// update_sambauser: samba_new_password) — see spec() and -// sambauserChangedFields. +// sambauserWriteFlags binds the add_sambauser request fields to the +// add command. update uses the disjoint sambauserUpdateFlags below — +// the same split the cronjob/ftpuser slices use — so update's --help +// does not describe add requirements. The password flag maps to +// add_sambauser's samba_password key (update sends +// samba_new_password) — see spec() and sambauserChangedFields. type sambauserWriteFlags struct { password string comment string @@ -67,9 +66,9 @@ type sambauserWriteFlags struct { func (f *sambauserWriteFlags) bind(cmd *cobra.Command) { fl := cmd.Flags() - fl.StringVar(&f.password, "password", "", "Samba password (required for add; new password for update)") - fl.StringVar(&f.comment, "comment", "", "user comment / label (required for add)") - fl.StringVar(&f.path, "path", "", "share path the user is granted (samba_path; required for add)") + fl.StringVar(&f.password, "password", "", "Samba password (required)") + fl.StringVar(&f.comment, "comment", "", "user comment / label (required)") + fl.StringVar(&f.path, "path", "", "share path the user is granted (samba_path; required)") } func (f *sambauserWriteFlags) spec() sambauser.Spec { @@ -97,16 +96,19 @@ func newSambaUsersAddCmd(opts *RootOptions) *cobra.Command { return writeSpec{}, fmt.Errorf("--path is required") } s := f.spec() + var createdID string return writeSpec{ action: "add_sambauser", destructive: false, confirm: ConfirmAction{Verb: "create", Resource: "samba user", ID: f.comment}, params: sambauser.AddParams(s), + createdID: &createdID, dispatch: func(c *api.Client, ctx context.Context) (string, error) { login, derr := sambauser.NewClient(c).Add(ctx, s) if derr != nil { return "", derr } + createdID = login return "created samba user " + login, nil }, }, nil @@ -116,6 +118,23 @@ func newSambaUsersAddCmd(opts *RootOptions) *cobra.Command { return cmd } +// sambauserUpdateFlags binds the update_sambauser mutable surface. +// Disjoint from sambauserWriteFlags so update's --help describes the +// flags as replacements instead of add requirements — the same split +// the cronjob/ftpuser slices use. +type sambauserUpdateFlags struct { + password string + comment string + path string +} + +func (f *sambauserUpdateFlags) bind(cmd *cobra.Command) { + fl := cmd.Flags() + fl.StringVar(&f.password, "password", "", "replacement Samba password (sent as samba_new_password)") + fl.StringVar(&f.comment, "comment", "", "replacement user comment / label") + fl.StringVar(&f.path, "path", "", "replacement share path the user is granted (samba_path)") +} + // sambauserChangedFields collects only the write flags the user // explicitly set into the update_sambauser field map (keyed on the // sambauser.Field* constants). Each field is a wholesale replacement @@ -124,7 +143,7 @@ func newSambaUsersAddCmd(opts *RootOptions) *cobra.Command { // ftpuser update uses. The password flag maps to samba_new_password // here (update_sambauser's key) rather than the add-only // samba_password. -func sambauserChangedFields(cmd *cobra.Command, f *sambauserWriteFlags) map[string]string { +func sambauserChangedFields(cmd *cobra.Command, f *sambauserUpdateFlags) map[string]string { fields := map[string]string{} if cmd.Flags().Changed("password") { fields[sambauser.FieldNewPassword] = f.password @@ -139,7 +158,7 @@ func sambauserChangedFields(cmd *cobra.Command, f *sambauserWriteFlags) map[stri } func newSambaUsersUpdateCmd(opts *RootOptions) *cobra.Command { - f := &sambauserWriteFlags{} + f := &sambauserUpdateFlags{} cmd := &cobra.Command{ Use: "update [password/path flags]", Short: "Replace mutable fields of a Samba user (update_sambauser)", diff --git a/internal/cronjob/cronjob_test.go b/internal/cronjob/cronjob_test.go index dc26649..0f4b2d6 100644 --- a/internal/cronjob/cronjob_test.go +++ b/internal/cronjob/cronjob_test.go @@ -60,7 +60,7 @@ func TestDecodeCronjobs(t *testing.T) { func TestDecodeCronjobSingular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "cronjob/get_cronjob_response_success.xml") + resp := testutil.DecodeFixture(t, "cronjob/get_cronjobs_response_success_single.xml") got, err := cronjob.DecodeCronjobs(resp.Body.ReturnInfo) if err != nil { t.Fatalf("DecodeCronjobs: %v", err) @@ -135,7 +135,7 @@ func TestClientList(t *testing.T) { func TestClientGet(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "cronjob/get_cronjob_response_success.xml") + resp := testutil.DecodeFixture(t, "cronjob/get_cronjobs_response_success_single.xml") fc := &testutil.FakeCaller{Resp: resp} c, err := cronjob.NewClient(fc).Get(context.Background(), "325208") if err != nil { @@ -206,7 +206,7 @@ func TestCronjobListTabular(t *testing.T) { func TestCronjobTabular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "cronjob/get_cronjob_response_success.xml") + resp := testutil.DecodeFixture(t, "cronjob/get_cronjobs_response_success_single.xml") list, _ := cronjob.DecodeCronjobs(resp.Body.ReturnInfo) if len(list) != 1 { t.Fatalf("len = %d, want 1", len(list)) diff --git a/internal/ddns/ddns_test.go b/internal/ddns/ddns_test.go index 945ec28..1aea422 100644 --- a/internal/ddns/ddns_test.go +++ b/internal/ddns/ddns_test.go @@ -53,7 +53,7 @@ func TestDecodeDDNSUsers(t *testing.T) { func TestDecodeDDNSUserSingular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "ddns/get_ddnsuser_response_success.xml") + resp := testutil.DecodeFixture(t, "ddns/get_ddnsusers_response_success_single.xml") got, err := ddns.DecodeDDNSUsers(resp.Body.ReturnInfo) if err != nil { t.Fatalf("DecodeDDNSUsers: %v", err) @@ -121,7 +121,7 @@ func TestClientList(t *testing.T) { func TestClientGet(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "ddns/get_ddnsuser_response_success.xml") + resp := testutil.DecodeFixture(t, "ddns/get_ddnsusers_response_success_single.xml") fc := &testutil.FakeCaller{Resp: resp} u, err := ddns.NewClient(fc).Get(context.Background(), "dyn0000002") if err != nil { @@ -160,7 +160,7 @@ func TestClientGetEmptyLogin(t *testing.T) { // array stripped down to zero entries. func TestClientGetNotFound(t *testing.T) { t.Parallel() - emptyResp := testutil.DecodeFixture(t, "ddns/get_ddnsuser_response_success.xml") + emptyResp := testutil.DecodeFixture(t, "ddns/get_ddnsusers_response_success_single.xml") emptyResp.Body.ReturnInfo.Array = nil c := ddns.NewClient(&testutil.FakeCaller{Resp: emptyResp}) _, err := c.Get(context.Background(), "ghost") @@ -206,7 +206,7 @@ func TestDDNSUserListTabular(t *testing.T) { func TestDDNSUserTabular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "ddns/get_ddnsuser_response_success.xml") + resp := testutil.DecodeFixture(t, "ddns/get_ddnsusers_response_success_single.xml") list, _ := ddns.DecodeDDNSUsers(resp.Body.ReturnInfo) if len(list) != 1 { t.Fatalf("len = %d, want 1", len(list)) diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go index ec32b3c..39b7b1c 100644 --- a/internal/domain/domain_test.go +++ b/internal/domain/domain_test.go @@ -44,7 +44,7 @@ func TestDecodeDomains(t *testing.T) { func TestDecodeDomainSingular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "domain/get_domain_response_success.xml") + resp := testutil.DecodeFixture(t, "domain/get_domains_response_success_single.xml") got, err := domain.DecodeDomains(resp.Body.ReturnInfo) if err != nil { t.Fatalf("DecodeDomains: %v", err) @@ -126,7 +126,7 @@ func TestClientList(t *testing.T) { func TestClientGet(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "domain/get_domain_response_success.xml") + resp := testutil.DecodeFixture(t, "domain/get_domains_response_success_single.xml") fc := &testutil.FakeCaller{Resp: resp} d, err := domain.NewClient(fc).Get(context.Background(), "example.com") if err != nil { @@ -221,7 +221,7 @@ func TestTLDListTabular(t *testing.T) { func TestDomainTabularSummarisesPEM(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "domain/get_domain_response_success.xml") + resp := testutil.DecodeFixture(t, "domain/get_domains_response_success_single.xml") list, _ := domain.DecodeDomains(resp.Body.ReturnInfo) rows := list[0].TableRows() for _, row := range rows { diff --git a/internal/ftpuser/ftpuser_test.go b/internal/ftpuser/ftpuser_test.go index e7239fc..f263e38 100644 --- a/internal/ftpuser/ftpuser_test.go +++ b/internal/ftpuser/ftpuser_test.go @@ -41,7 +41,7 @@ func TestDecodeFTPUsers(t *testing.T) { func TestDecodeFTPUserSingular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "ftpuser/get_ftpuser_response_success.xml") + resp := testutil.DecodeFixture(t, "ftpuser/get_ftpusers_response_success_single.xml") got, err := ftpuser.DecodeFTPUsers(resp.Body.ReturnInfo) if err != nil { t.Fatalf("DecodeFTPUsers: %v", err) @@ -68,7 +68,7 @@ func TestDecodeFTPUserSingular(t *testing.T) { func TestDecodeFTPUsersEmptyList(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "ftpuser/get_ftpuser_response_success_empty_list.xml") + resp := testutil.DecodeFixture(t, "ftpuser/get_ftpusers_response_success_empty_list.xml") got, err := ftpuser.DecodeFTPUsers(resp.Body.ReturnInfo) if err != nil { t.Fatalf("DecodeFTPUsers: %v", err) @@ -99,7 +99,7 @@ func TestClientList(t *testing.T) { func TestClientGet(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "ftpuser/get_ftpuser_response_success.xml") + resp := testutil.DecodeFixture(t, "ftpuser/get_ftpusers_response_success_single.xml") fc := &testutil.FakeCaller{Resp: resp} u, err := ftpuser.NewClient(fc).Get(context.Background(), "f0000001") if err != nil { @@ -126,7 +126,7 @@ func TestClientGetEmptyLogin(t *testing.T) { func TestClientGetNotFound(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "ftpuser/get_ftpuser_response_success_empty_list.xml") + resp := testutil.DecodeFixture(t, "ftpuser/get_ftpusers_response_success_empty_list.xml") c := ftpuser.NewClient(&testutil.FakeCaller{Resp: resp}) if _, err := c.Get(context.Background(), "missing"); err == nil { t.Errorf("Get on empty result err = nil, want not-found") @@ -164,7 +164,7 @@ func TestFTPUserListTabular(t *testing.T) { func TestFTPUserTabular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "ftpuser/get_ftpuser_response_success.xml") + resp := testutil.DecodeFixture(t, "ftpuser/get_ftpusers_response_success_single.xml") list, _ := ftpuser.DecodeFTPUsers(resp.Body.ReturnInfo) if len(list) != 1 { t.Fatalf("len = %d, want 1", len(list)) diff --git a/internal/mailaccount/mailaccount_test.go b/internal/mailaccount/mailaccount_test.go index c389f4f..0f1748d 100644 --- a/internal/mailaccount/mailaccount_test.go +++ b/internal/mailaccount/mailaccount_test.go @@ -43,7 +43,7 @@ func TestDecodeMailAccounts(t *testing.T) { func TestDecodeMailAccountSingular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "mailaccount/get_mailaccount_response_success.xml") + resp := testutil.DecodeFixture(t, "mailaccount/get_mailaccounts_response_success_single.xml") got, err := mailaccount.DecodeMailAccounts(resp.Body.ReturnInfo) if err != nil { t.Fatalf("DecodeMailAccounts: %v", err) @@ -84,7 +84,7 @@ func TestClientList(t *testing.T) { func TestClientGet(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "mailaccount/get_mailaccount_response_success.xml") + resp := testutil.DecodeFixture(t, "mailaccount/get_mailaccounts_response_success_single.xml") fc := &testutil.FakeCaller{Resp: resp} a, err := mailaccount.NewClient(fc).Get(context.Background(), "m0000001") if err != nil { @@ -148,7 +148,7 @@ func TestMailAccountListTabular(t *testing.T) { func TestMailAccountTabular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "mailaccount/get_mailaccount_response_success.xml") + resp := testutil.DecodeFixture(t, "mailaccount/get_mailaccounts_response_success_single.xml") list, _ := mailaccount.DecodeMailAccounts(resp.Body.ReturnInfo) rows := list[0].TableRows() if len(rows) == 0 { diff --git a/internal/mailforward/mailforward_test.go b/internal/mailforward/mailforward_test.go index 4314f30..d6985fc 100644 --- a/internal/mailforward/mailforward_test.go +++ b/internal/mailforward/mailforward_test.go @@ -37,7 +37,7 @@ func TestDecodeMailForwards(t *testing.T) { func TestDecodeMailForwardSingular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "mailforward/get_mailforward_response_success.xml") + resp := testutil.DecodeFixture(t, "mailforward/get_mailforwards_response_success_single.xml") got, err := mailforward.DecodeMailForwards(resp.Body.ReturnInfo) if err != nil { t.Fatalf("DecodeMailForwards: %v", err) @@ -71,7 +71,7 @@ func TestClientList(t *testing.T) { func TestClientGet(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "mailforward/get_mailforward_response_success.xml") + resp := testutil.DecodeFixture(t, "mailforward/get_mailforwards_response_success_single.xml") fc := &testutil.FakeCaller{Resp: resp} f, err := mailforward.NewClient(fc).Get(context.Background(), "from@example.de") if err != nil { diff --git a/internal/mailinglist/mailinglist_test.go b/internal/mailinglist/mailinglist_test.go index 9eec93d..80a4539 100644 --- a/internal/mailinglist/mailinglist_test.go +++ b/internal/mailinglist/mailinglist_test.go @@ -43,7 +43,7 @@ func TestDecodeMailingLists(t *testing.T) { func TestDecodeMailingListSingular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "mailinglist/get_mailinglist_response_success.xml") + resp := testutil.DecodeFixture(t, "mailinglist/get_mailinglists_response_success_single.xml") got, err := mailinglist.DecodeMailingLists(resp.Body.ReturnInfo) if err != nil { t.Fatalf("DecodeMailingLists: %v", err) @@ -102,7 +102,7 @@ func TestClientListEmpty(t *testing.T) { func TestClientGet(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "mailinglist/get_mailinglist_response_success.xml") + resp := testutil.DecodeFixture(t, "mailinglist/get_mailinglists_response_success_single.xml") fc := &testutil.FakeCaller{Resp: resp} m, err := mailinglist.NewClient(fc).Get(context.Background(), "announce-example-com") if err != nil { @@ -173,7 +173,7 @@ func TestMailingListListTabular(t *testing.T) { func TestMailingListSingularTabular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "mailinglist/get_mailinglist_response_success.xml") + resp := testutil.DecodeFixture(t, "mailinglist/get_mailinglists_response_success_single.xml") list, _ := mailinglist.DecodeMailingLists(resp.Body.ReturnInfo) if len(list) != 1 { t.Fatalf("len = %d, want 1", len(list)) diff --git a/internal/sambauser/sambauser_test.go b/internal/sambauser/sambauser_test.go index dab139c..f18b5bf 100644 --- a/internal/sambauser/sambauser_test.go +++ b/internal/sambauser/sambauser_test.go @@ -56,7 +56,7 @@ func TestClientList(t *testing.T) { func TestDecodeSambaUserSingular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "sambauser/get_sambauser_response_success.xml") + resp := testutil.DecodeFixture(t, "sambauser/get_sambausers_response_success_single.xml") got, err := sambauser.DecodeSambaUsers(resp.Body.ReturnInfo) if err != nil { t.Fatalf("DecodeSambaUsers: %v", err) @@ -75,7 +75,7 @@ func TestDecodeSambaUserSingular(t *testing.T) { func TestClientGet(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "sambauser/get_sambauser_response_success.xml") + resp := testutil.DecodeFixture(t, "sambauser/get_sambausers_response_success_single.xml") fc := &testutil.FakeCaller{Resp: resp} u, err := sambauser.NewClient(fc).Get(context.Background(), "s0000000") if err != nil { @@ -123,7 +123,7 @@ func TestClientPropagatesError(t *testing.T) { func TestSambaUserTabular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "sambauser/get_sambauser_response_success.xml") + resp := testutil.DecodeFixture(t, "sambauser/get_sambausers_response_success_single.xml") list, _ := sambauser.DecodeSambaUsers(resp.Body.ReturnInfo) if len(list) != 1 { t.Fatalf("len = %d, want 1", len(list)) diff --git a/internal/transport/client.go b/internal/transport/client.go index d90aa56..4c94c9c 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -199,10 +199,14 @@ func (c *Client) doOnce(ctx context.Context, endpoint string, body []byte) ([]by // a body must reach the decoder so the typed-fault path (auth // refresh, flood fallback, exit-code classification) applies // instead of three blind retries that discard the fault. The - // sniff matches any namespace prefix (SOAP-ENV:Fault, soap:Fault); - // only fault-free 5xx bodies (gateway/proxy errors) stay - // retryable. - if bytes.Contains(respBody, []byte(":Fault")) { + // sniff matches the Fault element with any namespace prefix + // (SOAP-ENV:Fault, soap:Fault) and the prefix-less + // default-namespace form. It is a byte-level heuristic: a + // non-SOAP 5xx body that happens to contain a marker is handed + // to the decoder and fails there as a non-retryable decode error + // — accepted, because real gateway/proxy error pages carry + // neither marker and so stay retryable. + if bytes.Contains(respBody, []byte(":Fault")) || bytes.Contains(respBody, []byte("` + + `Server` + + `flood_protection` + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, faultBody) + })) + defer srv.Close() + + c := newClient(srv, newFakeClock()) + body, err := c.Do(context.Background(), srv.URL, []byte(sampleEnvelope)) + if err != nil { + t.Fatalf("Do: %v", err) + } + if string(body) != faultBody { + t.Errorf("body = %q, want the prefix-less fault body passed through", body) + } + if calls.Load() != 1 { + t.Errorf("calls = %d, want 1 (fault body must not be retried)", calls.Load()) + } +} + func TestDoStopsRetryAfterMax(t *testing.T) { var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/testdata/cronjob/get_cronjob_request.xml b/testdata/cronjob/get_cronjobs_request_cronjob_id.xml similarity index 100% rename from testdata/cronjob/get_cronjob_request.xml rename to testdata/cronjob/get_cronjobs_request_cronjob_id.xml diff --git a/testdata/cronjob/get_cronjob_response_success.xml b/testdata/cronjob/get_cronjobs_response_success_single.xml similarity index 100% rename from testdata/cronjob/get_cronjob_response_success.xml rename to testdata/cronjob/get_cronjobs_response_success_single.xml diff --git a/testdata/ddns/get_ddnsuser_response_success.xml b/testdata/ddns/get_ddnsusers_response_success_single.xml similarity index 100% rename from testdata/ddns/get_ddnsuser_response_success.xml rename to testdata/ddns/get_ddnsusers_response_success_single.xml diff --git a/testdata/domain/get_domain_request.xml b/testdata/domain/get_domains_request_domain_name.xml similarity index 100% rename from testdata/domain/get_domain_request.xml rename to testdata/domain/get_domains_request_domain_name.xml diff --git a/testdata/domain/get_domain_response_success.xml b/testdata/domain/get_domains_response_success_single.xml similarity index 100% rename from testdata/domain/get_domain_response_success.xml rename to testdata/domain/get_domains_response_success_single.xml diff --git a/testdata/ftpuser/get_ftpuser_request.xml b/testdata/ftpuser/get_ftpusers_request_ftp_login.xml similarity index 100% rename from testdata/ftpuser/get_ftpuser_request.xml rename to testdata/ftpuser/get_ftpusers_request_ftp_login.xml diff --git a/testdata/ftpuser/get_ftpuser_response_success_empty_list.xml b/testdata/ftpuser/get_ftpusers_response_success_empty_list.xml similarity index 100% rename from testdata/ftpuser/get_ftpuser_response_success_empty_list.xml rename to testdata/ftpuser/get_ftpusers_response_success_empty_list.xml diff --git a/testdata/ftpuser/get_ftpuser_response_success.xml b/testdata/ftpuser/get_ftpusers_response_success_single.xml similarity index 100% rename from testdata/ftpuser/get_ftpuser_response_success.xml rename to testdata/ftpuser/get_ftpusers_response_success_single.xml diff --git a/testdata/mailaccount/get_mailaccount_request.xml b/testdata/mailaccount/get_mailaccounts_request_mail_login.xml similarity index 100% rename from testdata/mailaccount/get_mailaccount_request.xml rename to testdata/mailaccount/get_mailaccounts_request_mail_login.xml diff --git a/testdata/mailaccount/get_mailaccount_response_success.xml b/testdata/mailaccount/get_mailaccounts_response_success_single.xml similarity index 100% rename from testdata/mailaccount/get_mailaccount_response_success.xml rename to testdata/mailaccount/get_mailaccounts_response_success_single.xml diff --git a/testdata/mailforward/get_mailforward_request.xml b/testdata/mailforward/get_mailforwards_request_mail_forward.xml similarity index 100% rename from testdata/mailforward/get_mailforward_request.xml rename to testdata/mailforward/get_mailforwards_request_mail_forward.xml diff --git a/testdata/mailforward/get_mailforward_response_success.xml b/testdata/mailforward/get_mailforwards_response_success_single.xml similarity index 100% rename from testdata/mailforward/get_mailforward_response_success.xml rename to testdata/mailforward/get_mailforwards_response_success_single.xml diff --git a/testdata/mailinglist/get_mailinglist_request.xml b/testdata/mailinglist/get_mailinglists_request_mailinglist_name.xml similarity index 100% rename from testdata/mailinglist/get_mailinglist_request.xml rename to testdata/mailinglist/get_mailinglists_request_mailinglist_name.xml diff --git a/testdata/mailinglist/get_mailinglist_response_success.xml b/testdata/mailinglist/get_mailinglists_response_success_single.xml similarity index 100% rename from testdata/mailinglist/get_mailinglist_response_success.xml rename to testdata/mailinglist/get_mailinglists_response_success_single.xml diff --git a/testdata/sambauser/get_sambauser_response_success.xml b/testdata/sambauser/get_sambausers_response_success_single.xml similarity index 100% rename from testdata/sambauser/get_sambauser_response_success.xml rename to testdata/sambauser/get_sambausers_response_success_single.xml From aff6e1c53407b300db76e2e091baf12ce0e72325 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sat, 18 Jul 2026 13:26:39 +0200 Subject: [PATCH 16/16] docs: record the third-pass review fixes CHANGELOG umbrella entry for the High/Med/Low fixes, the CLAUDE.md fixture-naming example now uses a conforming plural-action name, the destructive-writes contract documents the new aborted audit outcome and the created_id field on success records of server-generated-ID creates, and the stale pre-rename fixture reference in the unreleased cronjob entry is corrected. --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++- CLAUDE.md | 2 +- docs/usage/destructive-writes.md | 9 +++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47fb711..9d07d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,7 +115,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `testdata/cronjob/{add_cronjob_response_success,add_cronjob_response_warning,update_cronjob_response_success}.xml` +- `testdata/cronjob/{add_cronjob_response_success,add_cronjob_response_success_warning,update_cronjob_response_success}.xml` carry a top-of-file XML comment documenting that KAS itself echoes the notification address under `mail_address` (double d) in the `KasRequestParams` echo block, while the documented request key is @@ -575,6 +575,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Whole-codebase re-review follow-up (third pass, High + Med + Low + findings): + - Group commands (`mail`, `accounts`, `config`, ...) invoked with an + unknown subcommand no longer print help and exit 0 — a typo'd + subcommand read as success to scripts. `cli.Finalize` now gives + every non-runnable group an explicit unknown-subcommand rejection + (exit 1, with cobra's "Did you mean this?" suggestions); a bare + group invocation keeps printing help with exit 0. + - The lazily-registered `completion` command is registered before the + exit-code walkers run, so its args-validation failures exit 1 (user + error) instead of 2. + - The success audit record of create actions whose identifier KAS + generates server-side (`add_ftpuser`, `add_database`, + `add_sambauser`, `add_ddnsuser`, `add_cronjob`, `add_mailaccount`, + `add_mailforward`, `add_mailinglist`) now carries the assigned + identifier as `created_id`, so the create correlates with the + identifier later update/delete records carry as their target. + - A confirmation-prompt I/O failure (neither a yes nor a no was read) + now leaves an audit record with the new outcome `aborted` instead + of silently skipping the trace of a blocked destructive attempt. + - The transport 5xx fault sniff also recognises a prefix-less + default-namespace `` element, matching what the SOAP decoder + accepts; an end-to-end api-layer test pins that a fault delivered + with HTTP 500 surfaces as the same typed `*api.Error` a 200-wrapped + fault produces. + - `sambausers update` binds its own replacement-flag set instead of + sharing `add`'s (whose `--help` texts claimed "required for add") — + the same add/update split the cronjob/ftpuser slices got in the + second pass. + - The remaining `get_` fixtures whose filename encoded a + non-existent singular KAS action while embedding the plural one + were renamed to `__.xml` across + `testdata/{cronjob,ddns,domain,ftpuser,mailaccount,mailforward,mailinglist,sambauser}/` + (e.g. `get_ftpuser_response_success.xml` → + `get_ftpusers_response_success_single.xml`), and the convention + example in `CLAUDE.md` now uses a conforming name. + - Whole-codebase re-review follow-up (second pass, Med + Low findings): - Bad user input now consistently exits 1: cobra positional-args failures (e.g. a missing required argument) and an unknown root diff --git a/CLAUDE.md b/CLAUDE.md index c71dcb0..6d18eec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Filename convention: - One file per `(kas_action, kind)` pair: `_[_].xml`, where `kind` is `request` or `response_`, and `status` is `success` or `failed`. Examples: - `get_accounts_response_success.xml` - `add_account_response_failed_account_kas_password_syntax_incorrect.xml` - - `get_ftpuser_response_success_empty_list.xml` (variant of the success shape) + - `get_ftpusers_response_success_empty_list.xml` (variant of the success shape; the `` prefix is always the real — plural — action the file embeds, never a singular alias) Add a new fixture whenever a new KAS call is wired up; redact secrets before committing. diff --git a/docs/usage/destructive-writes.md b/docs/usage/destructive-writes.md index 1595dd0..57537fd 100644 --- a/docs/usage/destructive-writes.md +++ b/docs/usage/destructive-writes.md @@ -111,7 +111,14 @@ ts=2026-05-16T12:00:00Z login=w0000001 action=delete_dns_settings target="record bare `failure` for a transport/decode error. A destructive attempt that never dispatched is also recorded: `declined` when the `[y/N]` prompt was answered with no, `refused` when stdin was not a TTY and `--yes` -was not given. +was not given, `aborted` when the interactive prompt failed with an +I/O error before an answer was read. + +For the `add` actions whose identifier KAS generates server-side (the +slices listed in the baseline above), the success record additionally +carries the assigned identifier as `created_id=`, so the create +can be correlated with the identifier later `update`/`delete` records +carry as their `target`. Passing `--audit-log ` (or setting `KAS_AUDIT_LOG`; the flag wins) additionally appends the same record as one JSON object per line