Files
Enterprise-Knowledge-Base/cli/cmd/doc/upload_recursive.go
T
nullkey cc8254f862 refactor(cli): drop --dry-run + introduce bare-JSON output path
Two intertwined mainstream-alignment moves bundled because they share
the migration target (every command's --json path):

1. Drop --dry-run entirely. Survey of comparable API-wrapper CLIs
   (gh, aws, stripe, lark): none expose --dry-run. The mainstream that
   does (kubectl/git/helm/ansible) operates on declarative manifests
   or local state where the preview is materially different from the
   executed action. WeKnora's CLI just echoed the same parameters
   that would have gone on the wire — the preview added no real
   signal over `--help` + reading the call site. Removes:
   - root --dry-run persistent flag + cmdutil/dryrun.go
   - DryRun fields + EmitDryRun calls in 12 write commands
   - format.Envelope.DryRun field
   - 8 corresponding *_test.go cases
   - --dry-run mention from README.md and CHANGELOG.md
   - "dry_run":false from 16 golden envelopes

2. Migrate every --json output to bare data:
   - New format.WriteJSON / WriteJSONFiltered helpers
     (cli/internal/format/bare.go) share filterArrayItems /
     filterObjectKeys / writeJQ with the (still-live for now) envelope
     filter helpers.
   - Read commands (kb/doc/session list+view, search chunks/docs/
     sessions/kb, auth list/status, agent list/view, context list,
     doctor) emit bare arrays / objects on stdout.
   - Write commands (kb create/edit/delete/pin/empty, doc upload/
     upload_recursive/delete, session delete, auth login/logout/
     refresh/token, link/unlink, context add/use/remove, agent
     invoke, chat, api, version) emit bare result objects. Risk
     classification dropped — the resource + exit code already
     convey the action.

Per-command shape changes:
   list / search       → []T   (was {ok, data:{items:[…]}})
   view                → T     (was {ok, data:T, _meta:…})
   create / edit       → T
   delete / pin / etc. → {id, …action result…}
   doctor              → {summary, checks}
   api                 → {status, headers, body}

_meta dropped on the read path:
   pagination (page/page_size/total/has_more) — agents iterate with
   --all-pages or accept --limit (gh CLI parity);
   kb_id / context echo — caller already knows what it asked for.

Acceptance contract goldens regenerated for the new bare shape.
Error envelope on stdout (PrintErrorEnvelope) stays live for now —
the envelope-infra deletion lands in the next commit.
2026-05-15 12:03:56 +08:00

168 lines
5.3 KiB
Go

package doc
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// uploadOutcome is one entry in the recursive upload's per-file report.
type uploadOutcome struct {
Path string `json:"path"`
ID string `json:"id,omitempty"`
Error string `json:"error,omitempty"`
}
// runUploadRecursive walks dir, filters by Glob, and uploads each match
// sequentially. Per-file errors do NOT abort the walk — they accumulate
// and the final return aggregates them so the user sees the full picture
// in one run. Exit semantics: nil error on full success, a typed *cmdutil.Error
// when ≥1 file failed (the typed code mirrors the first failure's
// classification so callers can still branch).
func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil.JSONOptions, svc UploadService, kbID, dir string) error {
if opts.Name != "" {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: "--name cannot be combined with --recursive (one name can't apply to N files)",
Hint: "drop --name or upload files one at a time",
}
}
info, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
return cmdutil.Wrapf(cmdutil.CodeUploadFileNotFound, err, "directory not found: %s", dir)
}
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "stat %s", dir)
}
if !info.IsDir() {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("not a directory: %s (drop --recursive to upload a single file)", dir),
}
}
// Sanity-check the pattern up front so a typo doesn't show up as "no
// files matched" per-file. Cobra populates --glob; tests pass it
// explicitly — no in-function default needed.
if _, err := filepath.Match(opts.Glob, ""); err != nil {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("invalid --glob %q: %v", opts.Glob, err),
}
}
matches, err := walkMatches(dir, opts.Glob)
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "walk %s", dir)
}
if len(matches) == 0 {
if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, recursiveResult{KBID: kbID}, jopts.Fields, jopts.JQ)
}
fmt.Fprintf(iostreams.IO.Out, "(no files matched %q under %s)\n", opts.Glob, dir)
return nil
}
var uploaded, failed []uploadOutcome
var firstFailCode cmdutil.ErrorCode
for _, p := range matches {
k, err := svc.CreateKnowledgeFromFile(ctx, kbID, p, nil, nil, "", uploadChannel)
if err != nil {
code := cmdutil.ClassifyHTTPError(err)
if firstFailCode == "" {
firstFailCode = code
}
failed = append(failed, uploadOutcome{Path: p, Error: err.Error()})
// Per-file progress lines are human progress signal; suppress
// under --json so they don't precede the envelope on stdout.
if !jopts.Enabled() {
fmt.Fprintf(iostreams.IO.Out, "FAIL %s: %v\n", filepath.Base(p), err)
}
continue
}
id := ""
if k != nil {
id = k.ID
}
uploaded = append(uploaded, uploadOutcome{Path: p, ID: id})
if !jopts.Enabled() {
fmt.Fprintf(iostreams.IO.Out, "OK %s (id: %s)\n", filepath.Base(p), id)
}
}
if jopts.Enabled() {
result := recursiveResult{KBID: kbID, Uploaded: uploaded, Failed: failed}
if err := format.WriteJSONFiltered(iostreams.IO.Out, result, jopts.Fields, jopts.JQ); err != nil {
return err
}
} else {
fmt.Fprintf(iostreams.IO.Out, "Uploaded %d, Failed %d\n", len(uploaded), len(failed))
}
if len(failed) > 0 {
// Silent on the --json path: the success object above already
// carries per-file uploaded[]/failed[] detail; without Silent the
// root error handler would print to stderr in addition. ExitCode
// still walks Code so the typed exit-code-by-class contract holds.
return &cmdutil.Error{
Code: firstFailCode,
Message: fmt.Sprintf("%d of %d uploads failed", len(failed), len(matches)),
Silent: jopts.Enabled(),
}
}
return nil
}
// recursiveResult is the JSON shape emitted under data when --recursive is
// combined with --json. Mirrors the human-mode per-file output: a list of
// successes (Uploaded) and a list of failures (Failed), each with the
// originating path so agents can re-try only the failed entries.
type recursiveResult struct {
KBID string `json:"kb_id"`
Uploaded []uploadOutcome `json:"uploaded,omitempty"`
Failed []uploadOutcome `json:"failed,omitempty"`
}
// walkMatches returns every regular file under root whose base name matches
// pattern. Order is filepath.WalkDir's lexical order (stdlib guarantee on
// every supported FS), which is deterministic for test assertions.
func walkMatches(root, pattern string) ([]string, error) {
var out []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// Skip non-regular files (sockets, devices); the SDK can't upload
// them and they'd show as opaque server errors.
info, ierr := d.Info()
if ierr != nil {
return ierr
}
if !info.Mode().IsRegular() {
return nil
}
ok, merr := filepath.Match(pattern, d.Name())
if merr != nil {
return merr
}
if ok {
out = append(out, p)
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}