Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35239259fb | ||
|
|
85bf2dfc8a | ||
|
|
c4f2ab631b | ||
|
|
308e71c783 | ||
|
|
ecce09b355 | ||
|
|
1d02ff805d | ||
|
|
4d843cf7a4 | ||
|
|
8560830d3e | ||
|
|
fb9ff7de73 | ||
|
|
5ee80cdb97 | ||
|
|
bf2c0653ed | ||
|
|
e4daddf9cf | ||
|
|
e92309f7c4 | ||
|
|
7a58b0d19a | ||
|
|
78e6f11d72 | ||
|
|
9a8a41a318 | ||
|
|
af8e6a9ccc | ||
|
|
547020f47e | ||
|
|
d5eee82816 | ||
|
|
0d8763b917 | ||
|
|
600404abd0 | ||
|
|
247926d0fa | ||
|
|
9ef2a4e652 | ||
|
|
d4daf9525c | ||
|
|
63a89e68fa | ||
|
|
29b73a7d5e | ||
|
|
3488e11129 | ||
|
|
596bdce3a1 | ||
|
|
58eea98f6c |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
|
||||
activity shortcuts with strict collection validation, cursor handling,
|
||||
write-terminal evidence, safe read-backs where the backend supports them,
|
||||
task-oriented routing, and documented backend
|
||||
boundaries.
|
||||
@@ -151,6 +151,10 @@ jobs:
|
||||
filename.startsWith('scripts/') ||
|
||||
filename.startsWith('verify/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
// Shortcut declarations feed the live command tree and Schema
|
||||
// assembly. Their reverse dependencies include the expensive
|
||||
// app and generator packages, which must run in separate shards.
|
||||
filename.startsWith('internal/shortcut/') ||
|
||||
filename.startsWith('internal/generator/') ||
|
||||
filename.startsWith('internal/cli/schema') ||
|
||||
// Parameter aliases are reduced against the live command tree.
|
||||
@@ -509,11 +513,28 @@ jobs:
|
||||
run: node .github/reviewer-routing.test.js
|
||||
|
||||
test-focused:
|
||||
name: Test (changed packages)
|
||||
name: "Test (focused: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Each shard owns one bounded slice of the impacted set, so no single job
|
||||
# carries internal/app together with every reverse dependency. The shard
|
||||
# list and per-shard execution below mirror test-race, which runs the same
|
||||
# shards at full-suite scope; release-scripts is included because its
|
||||
# dedicated job only runs at full-suite or release-sensitive scope, and
|
||||
# dropping it here would stop testing test/scripts changes entirely.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
- smoke
|
||||
- remaining
|
||||
- release-scripts
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -542,23 +563,77 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Test changed packages and reverse dependencies
|
||||
- name: Select impacted packages for shard
|
||||
id: select
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
list-shard "$TEST_SHARD" "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$package_output" ]; then
|
||||
echo "No buildable Go package is affected by this revision." \
|
||||
echo "No buildable Go package in shard $TEST_SHARD is affected by this revision." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
echo "affected=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
mapfile -t packages <<< "$package_output"
|
||||
go test -v -race -count=1 -timeout=15m "${packages[@]}"
|
||||
# The package list travels through a file rather than a step output:
|
||||
# reading it with `mapfile < file` has unambiguous line semantics,
|
||||
# whereas a here-string over a multi-line output would append an extra
|
||||
# empty element if the value ever carried a trailing newline, and an
|
||||
# empty element would reach go test as an empty package argument.
|
||||
printf '%s\n' "$package_output" > "$RUNNER_TEMP/focused-shard-packages.txt"
|
||||
echo "affected=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build
|
||||
if: ${{ matrix.shard == 'remaining' && steps.select.outputs.affected == 'true' }}
|
||||
run: make build
|
||||
|
||||
- name: Install archive tooling
|
||||
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Test shard with Race Detection
|
||||
if: ${{ steps.select.outputs.affected == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t packages < "$RUNNER_TEMP/focused-shard-packages.txt"
|
||||
test "${#packages[@]}" -gt 0
|
||||
for package in "${packages[@]}"; do
|
||||
test -n "$package" || {
|
||||
echo "shard package list contains an empty entry" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
if [ "$TEST_SHARD" = "app" ]; then
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Isolate Schema assembly and
|
||||
# bounded name ranges so each process releases that state on exit.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$TEST_SHARD" = "release-scripts" ]; then
|
||||
# Mirror the dedicated release-contract job: these suites shell out
|
||||
# to archive tooling and are not race-instrumented there.
|
||||
go test -v -count=1 -timeout=10m "${packages[@]}"
|
||||
exit 0
|
||||
fi
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
|
||||
# give them a dedicated package timeout on slower hosted runners.
|
||||
timeout_budget=12m
|
||||
if [ "$TEST_SHARD" = "cli" ] ||
|
||||
[ "$TEST_SHARD" = "smoke" ]; then
|
||||
timeout_budget=15m
|
||||
fi
|
||||
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
|
||||
|
||||
test-race:
|
||||
name: "Test (race: ${{ matrix.shard }})"
|
||||
@@ -711,7 +786,7 @@ jobs:
|
||||
failed=0
|
||||
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
|
||||
for shard in \
|
||||
"changed packages:$FOCUSED_RESULT" \
|
||||
"focused shards:$FOCUSED_RESULT" \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT" \
|
||||
@@ -740,7 +815,7 @@ jobs:
|
||||
release_expected=success
|
||||
fi
|
||||
for shard in \
|
||||
"changed packages:$FOCUSED_RESULT:$focused_expected" \
|
||||
"focused shards:$FOCUSED_RESULT:$focused_expected" \
|
||||
"race shards:$RACE_RESULT:$race_expected" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT:$release_expected" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT:success"
|
||||
|
||||
@@ -20,6 +20,10 @@ test/cli_compat/testdata/
|
||||
.gitignore
|
||||
.worktrees/
|
||||
.qoder/
|
||||
_logs/
|
||||
_docs/
|
||||
_output/
|
||||
vendor/
|
||||
|
||||
# Secrets & credentials
|
||||
.env
|
||||
|
||||
+66
-2
@@ -15,12 +15,76 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
var exit = os.Exit
|
||||
var (
|
||||
appExecute = app.ExecuteWithTelemetry
|
||||
resolveTelemetryIdentity = app.ResolveTelemetryIdentity
|
||||
trackRun = func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
clitrack.New(cfg).Run(execute, exitCode)
|
||||
}
|
||||
)
|
||||
|
||||
// trackedExitError tells clitrack that the command failed without asking it to
|
||||
// print the error a second time. The already-rendered message is published via
|
||||
// ExtraFields c5, while app.Execute remains the sole owner of presentation.
|
||||
type trackedExitError struct{}
|
||||
|
||||
func (trackedExitError) Error() string { return "" }
|
||||
|
||||
func trackerConfig(identity app.TelemetryIdentity, commandPath, errorMessage *string) clitrack.Config {
|
||||
return clitrack.Config{
|
||||
PID: "wcCRwZ",
|
||||
App: "dws",
|
||||
Version: app.RawVersion(),
|
||||
UID: identity.UserID,
|
||||
Username: identity.UserName,
|
||||
NoCommandLine: true,
|
||||
NoCwd: true,
|
||||
NoAutomaticDimensions: true,
|
||||
ExtraFields: func() map[string]string {
|
||||
fields := map[string]string{"c9": *commandPath}
|
||||
if identity.CorpID != "" {
|
||||
fields["c10"] = identity.CorpID
|
||||
}
|
||||
if *errorMessage != "" {
|
||||
fields["c5"] = *errorMessage
|
||||
}
|
||||
return fields
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func telemetryOptedOut() bool {
|
||||
return strings.TrimSpace(os.Getenv("DO_NOT_TRACK")) != ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
exit(app.Execute())
|
||||
optedOut := telemetryOptedOut()
|
||||
identity := app.TelemetryIdentity{}
|
||||
if !optedOut {
|
||||
identity = resolveTelemetryIdentity(os.Args[1:])
|
||||
}
|
||||
exitCode := 0
|
||||
commandPath := "dws"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(identity, &commandPath, &errorMessage)
|
||||
if optedOut {
|
||||
cfg.PID = ""
|
||||
}
|
||||
trackRun(
|
||||
cfg,
|
||||
func() error {
|
||||
exitCode, commandPath, errorMessage = appExecute()
|
||||
if exitCode != 0 {
|
||||
return trackedExitError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func(error) int { return exitCode },
|
||||
)
|
||||
}
|
||||
|
||||
+206
-13
@@ -1,27 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMainExitsWithSuccessfulVersionCommand(t *testing.T) {
|
||||
previousExit := exit
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
exit = previousExit
|
||||
os.Args = previousArgs
|
||||
})
|
||||
func TestCrossPlatformCoverageMainRunsThroughCLITracker(t *testing.T) {
|
||||
for _, wantCode := range []int{0, 1, 3, 5} {
|
||||
t.Run(fmt.Sprintf("exit_%d", wantCode), func(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "")
|
||||
wantError := ""
|
||||
if wantCode != 0 {
|
||||
wantError = "synthetic failure"
|
||||
}
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "read", "--profile", "corp-a"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func(args []string) app.TelemetryIdentity {
|
||||
if strings.Join(args, " ") != "sheet read --profile corp-a" {
|
||||
t.Fatalf("telemetry identity args = %#v", args)
|
||||
}
|
||||
return app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return wantCode, "sheet read", wantError })
|
||||
called := false
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
called = true
|
||||
if cfg.PID != "wcCRwZ" || cfg.App != "dws" {
|
||||
t.Fatalf("tracker identity = PID %q App %q", cfg.PID, cfg.App)
|
||||
}
|
||||
if cfg.Version != app.RawVersion() {
|
||||
t.Fatalf("tracker Version = %q, want %q", cfg.Version, app.RawVersion())
|
||||
}
|
||||
if !cfg.NoCommandLine || !cfg.NoCwd || !cfg.NoAutomaticDimensions || cfg.CaptureOutput {
|
||||
t.Fatalf("tracker privacy config = NoCommandLine %v NoCwd %v NoAutomaticDimensions %v CaptureOutput %v", cfg.NoCommandLine, cfg.NoCwd, cfg.NoAutomaticDimensions, cfg.CaptureOutput)
|
||||
}
|
||||
if cfg.Env != "" || cfg.EventID != "" || cfg.Endpoint != "" || cfg.FlushTimeout != 0 || cfg.OutputMaxLen != 0 {
|
||||
t.Fatalf("tracker SDK defaults were overridden: %#v", cfg)
|
||||
}
|
||||
if cfg.UID != "user-1" || cfg.Username != "Alice" || cfg.UserType != "" {
|
||||
t.Fatalf("tracker user identity = UID %q Username %q UserType %q", cfg.UID, cfg.Username, cfg.UserType)
|
||||
}
|
||||
|
||||
err := execute()
|
||||
if wantCode == 0 && err != nil {
|
||||
t.Fatalf("successful tracked execute error = %v", err)
|
||||
}
|
||||
if wantCode != 0 && (err == nil || err.Error() != "") {
|
||||
t.Fatalf("failed tracked execute error = %#v, want empty sentinel", err)
|
||||
}
|
||||
if gotCode := exitCode(err); gotCode != wantCode {
|
||||
t.Fatalf("tracked exit code = %d, want %d", gotCode, wantCode)
|
||||
}
|
||||
fields := cfg.ExtraFields()
|
||||
if fields["c9"] != "sheet read" || fields["c10"] != "corp-1" || fields["c5"] != wantError {
|
||||
t.Fatalf("tracker extra fields = %#v, want command path, corp ID, and error %q", fields, wantError)
|
||||
}
|
||||
if (wantError == "" && len(fields) != 2) || (wantError != "" && len(fields) != 3) {
|
||||
t.Fatalf("tracker extra field count = %d for error %q", len(fields), wantError)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
if !called {
|
||||
t.Fatalf("trackRun was not called for exit code %d", wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerConfigOmitsEmptyOrganization(t *testing.T) {
|
||||
commandPath := "version"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{}, &commandPath, &errorMessage)
|
||||
if cfg.UID != "" {
|
||||
t.Fatalf("empty identity UID = %q", cfg.UID)
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
t.Fatalf("empty identity Username = %q", cfg.Username)
|
||||
}
|
||||
if fields := cfg.ExtraFields(); len(fields) != 1 || fields["c9"] != "version" {
|
||||
t.Fatalf("empty organization fields = %#v", fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDefaultTrackRunNoopTracker(t *testing.T) {
|
||||
called := false
|
||||
code := -1
|
||||
exit = func(value int) {
|
||||
trackRun(clitrack.Config{}, func() error {
|
||||
called = true
|
||||
code = value
|
||||
return nil
|
||||
}, nil)
|
||||
if !called {
|
||||
t.Fatal("default tracker did not execute callback")
|
||||
}
|
||||
os.Args = []string{"dws", "version"}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMainRespectsDoNotTrack(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "1")
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "version"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func([]string) app.TelemetryIdentity {
|
||||
t.Fatal("DO_NOT_TRACK must skip telemetry identity reads")
|
||||
return app.TelemetryIdentity{}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return 0, "version", "" })
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
if cfg.PID != "" || cfg.UID != "" || cfg.Username != "" {
|
||||
t.Fatalf("opted-out tracker config = %#v", cfg)
|
||||
}
|
||||
if err := execute(); err != nil {
|
||||
t.Fatalf("opted-out execution failed: %v", err)
|
||||
}
|
||||
if code := exitCode(nil); code != 0 {
|
||||
t.Fatalf("opted-out exit code = %d, want 0", code)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
if !called || code != 0 {
|
||||
t.Fatalf("main exit = called %v, code %d", called, code)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerPayloadUsesReviewedFieldWhitelist(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "sheet", "read", "--access-token", "must-not-leak"}
|
||||
t.Setenv("SHELL", "/bin/zsh")
|
||||
t.Setenv("TERM_SESSION_ID", "stable-session")
|
||||
t.Setenv("TMUX_PANE", "%42")
|
||||
t.Setenv("LANG", "zh_CN.UTF-8")
|
||||
t.Setenv("LC_ALL", "zh_CN.UTF-8")
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
requestBody := make(chan []byte, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
requestBody <- body
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
commandPath := "sheet read"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}, &commandPath, &errorMessage)
|
||||
cfg.Endpoint = server.URL
|
||||
cfg.FlushTimeout = time.Second
|
||||
clitrack.New(cfg).Run(func() error { return nil }, nil)
|
||||
|
||||
var body []byte
|
||||
select {
|
||||
case body = <-requestBody:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for telemetry request")
|
||||
}
|
||||
var envelope map[string]string
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("decode telemetry request %q: %v", body, err)
|
||||
}
|
||||
decoded, err := url.QueryUnescape(envelope["gokey"])
|
||||
if err != nil {
|
||||
t.Fatalf("decode gokey: %v", err)
|
||||
}
|
||||
globalFields, err := url.ParseQuery(decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("parse global telemetry fields: %v", err)
|
||||
}
|
||||
eventFields, err := url.ParseQuery(globalFields.Get("msg"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse event telemetry fields: %v", err)
|
||||
}
|
||||
|
||||
assertTelemetryKeys(t, globalFields, []string{"app_name", "app_version", "env", "msg", "pid", "platform", "uid", "username", "version"})
|
||||
assertTelemetryKeys(t, eventFields, []string{"c1", "c10", "c3", "c4", "c9", "p1", "p4", "ts", "type"})
|
||||
for key, want := range map[string]string{
|
||||
"app_name": "dws", "app_version": app.RawVersion(), "env": "prod", "pid": "wcCRwZ",
|
||||
"platform": "cli", "uid": "user-1", "username": "Alice", "version": app.RawVersion(),
|
||||
} {
|
||||
if got := globalFields.Get(key); got != want {
|
||||
t.Fatalf("global telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"type": "event", "p1": "cli.exec", "p4": "SYS", "c1": "dws", "c3": "0", "c9": "sheet read", "c10": "corp-1",
|
||||
} {
|
||||
if got := eventFields.Get(key); got != want {
|
||||
t.Fatalf("event telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} {
|
||||
if globalFields.Has(key) {
|
||||
t.Fatalf("global telemetry leaked %s: %q", key, decoded)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"c2", "c5", "c6", "c7", "c8"} {
|
||||
if eventFields.Has(key) {
|
||||
t.Fatalf("event telemetry leaked %s: %q", key, globalFields.Get("msg"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertTelemetryKeys(t *testing.T, fields url.Values, want []string) {
|
||||
t.Helper()
|
||||
got := make([]string, 0, len(fields))
|
||||
for key := range fields {
|
||||
got = append(got, key)
|
||||
}
|
||||
sort.Strings(got)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("telemetry keys = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷
|
||||
| `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) |
|
||||
| `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) |
|
||||
| `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) |
|
||||
| `Input` | 额外取值来源:`@path` 读文件 / `-` 读 stdin,在 required/enum/约束/`Validate` 之前原地解析 | 否(今日:能力由作者写进 `Usage` / `SchemaDescription` 文案,是已声明事实而非推断;不另造 flag。目标形态收敛为类型化投影字段,见 RFC §5.3) |
|
||||
|
||||
#### 1.2.2 编排 / 执行字段(不算声明)
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ Definition(仅声明;不可编译)
|
||||
|
||||
下列字段**是**框架声明面(经 `corecmd.New` 生效并嵌入 `dws.schema.*`):
|
||||
|
||||
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面)
|
||||
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面;`Input` 是取值来源声明,经 `corecmd.New` 生效但**不**嵌入 `dws.schema.*`,能力靠 `Usage` 文案声明,见 §5.3)
|
||||
- `Constraints`
|
||||
- **非空** `Risk`(空值 = 运行时当只读确认,且**不**嵌入 `dws.schema.risk`)
|
||||
- `ConstParams`(载荷声明;不上用户 flag 表)
|
||||
@@ -673,6 +673,52 @@ func (k Key[T]) Declare(opts ...FlagOption[T]) FlagSpec
|
||||
- 构造时拒绝 `InputSourceInvalid`。
|
||||
- 当前没有任何 Shortcut 或 Leaf 声明 `Input`,因此 M1 增加能力且零上线表面变化。让现有命令采用它属于 §9 下的用户可见变更。
|
||||
|
||||
`Input` 的框架能力今日已在 `corecmd` 落地(声明即执行的过渡形态,语义与上文目标一致),使用指南:
|
||||
|
||||
**今日声明形态**:`FlagSpec.Input []string`,源常量 `corecmd.InputFile`(`"file"`)/ `corecmd.InputStdin`(`"stdin"`)。`helpers.LeafFlag` 是 `corecmd.FlagSpec` 别名,直接可用;`shortcut.Flag.Input` 同形声明,经 `FromShortcut` 映射到 `FlagSpec`。
|
||||
|
||||
```go
|
||||
// LeafSpec / helpers
|
||||
Flags: []helpers.LeafFlag{
|
||||
{
|
||||
Name: "content",
|
||||
Usage: "文档内容(支持 @文件路径 或 - 读 stdin)",
|
||||
Bind: "content",
|
||||
Input: []string{corecmd.InputFile, corecmd.InputStdin},
|
||||
},
|
||||
}
|
||||
|
||||
// shortcut
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "markdown", Desc: "Markdown 内容(支持 @文件路径 或 -)",
|
||||
Input: []string{"file", "stdin"}},
|
||||
}
|
||||
```
|
||||
|
||||
**运行时语义**(`resolveInputFlags`,在 `runDeclaredPreflight` 内、required/enum/约束/Validate 之前执行,原地改写 cobra flag 值):
|
||||
|
||||
- `--flag @path`:文件内容替换取值;`--flag -`:stdin 内容替换取值。
|
||||
- `--flag @@value`:转义为字面 `@value`,不做来源解析。
|
||||
- 只解析显式 CLI token(主名或别名);EnvVar 回落与注册默认值透传不解析。
|
||||
- 内容前置剥离 UTF-8 BOM;`Trim` 等既有语义照常作用于解析后的值。
|
||||
- 读取失败、源不支持、`@` 后空路径都是类型化校验错误(退出码 3);同时声明两种源而文件读取失败时附 stdin 引导 hint。
|
||||
|
||||
**作者守则**:
|
||||
|
||||
- 声明即全部能力:required/enum/约束/Validate 校验的已是解析后的真实内容,`Execute`/`Invoke` 无需任何额外代码。
|
||||
- `Usage`/`Desc` 必须写明支持 `@路径`/`-`;框架不自动改写 help 文案,今日也不向 Schema 投影(新增投影字段须先过 homology 评审,避免 catalog drift)。
|
||||
- `user_required` 确认的写命令若声明 `InputStdin`:stdin 在校验阶段被消费,交互确认将 fail-closed 为 `confirmation_required`,此类调用必须显式 `--yes`(或 `--dry-run`)。
|
||||
- **声明前先确认取值空间不会被前缀吃掉**:声明 `InputFile` 后,任何以 `@` 开头的合法值都会被当成文件路径(本产品尤其常见的是 at 提及类取值,如 `--at-user @zhangsan` 会报读取文件失败),用户只能改用 `@@` 转义;声明 `InputStdin` 后字面值 `-` 不可达(与 curl 等约定一致)。若该 flag 的正常取值可能命中这两种形态,就不要声明对应来源。
|
||||
- 声明在构造期校验(fail-closed panic):仅限 `KindString`;源值必须是 `file`/`stdin` 且不重复。
|
||||
|
||||
**今日实现与目标形态的差异**(迁移到本节目标 `FlagSpec` 时收敛):
|
||||
|
||||
| 维度 | 今日 | 目标 |
|
||||
|---|---|---|
|
||||
| 源类型 | `[]string` 常量 | 类型化 `InputSource` |
|
||||
| 路径边界 | 直接本地文件 IO | 复用 §5.5.2 本地文件 effect 边界 |
|
||||
| Schema 投影 | 无(靠作者在 Usage 声明) | 声明即最终源,随 Catalog 透传 |
|
||||
|
||||
核心 FlagSpec 故意没有:
|
||||
|
||||
- `Bind`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-12T00:10:44.511794",
|
||||
"count": 399,
|
||||
"generated_at": "2026-08-14T13:50:36.324505",
|
||||
"count": 418,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3659,11 +3659,204 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+delete-space",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "删除前读取影响目标,经高风险确认后只接受 success=true 终态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+feed-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "补充知识库协作动态查询,严格验证 feeds 并保留游标。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-add",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-remove",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+move",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+move-to-drive",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-copy",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "补充库内关键词和扩展名搜索,并拒绝假空结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+resolve-space",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建后要求 workspaceId 并通过空间详情读回验证真实落库。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "补充知识库详情入口,并要求 workspaceId 业务证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+wiki-new-doc",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。",
|
||||
"availability": "available"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DWS Wiki Shortcut 全景评审</title>
|
||||
<style>
|
||||
:root{--ink:#14213d;--muted:#5c677d;--line:#dbe4f0;--paper:#fff;--bg:#f3f7fb;--blue:#1769e0;--cyan:#00a6a6;--green:#178746;--amber:#a45b00;--red:#b42318;--shadow:0 14px 34px rgba(20,33,61,.08)}
|
||||
*{box-sizing:border-box}body{margin:0;overflow-x:hidden;background:linear-gradient(150deg,#edf5ff 0,#f8fbff 45%,#eef8f5 100%);color:var(--ink);font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}
|
||||
main,.card,.two>*{min-width:0}main{width:min(1180px,calc(100% - 32px));margin:28px auto 72px}.hero,.card{background:rgba(255,255,255,.96);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow)}
|
||||
.hero{padding:38px;background:radial-gradient(circle at 95% 0,#dff8f3,transparent 36%),linear-gradient(135deg,#fff,#f5f9ff)}h1{font-size:34px;line-height:1.2;margin:0 0 10px}.lead{font-size:17px;color:var(--muted);max-width:900px}.meta{display:flex;gap:10px;flex-wrap:wrap;margin-top:20px}.pill{border:1px solid #cbd9ea;border-radius:999px;padding:5px 11px;background:#fff;font-size:13px}
|
||||
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}.metric{padding:20px}.metric b{display:block;font-size:31px;color:var(--blue)}.metric span{color:var(--muted)}
|
||||
section{margin-top:22px}.card{padding:26px}h2{font-size:23px;margin:0 0 14px}h3{font-size:17px;margin:22px 0 8px}.callout{border-left:4px solid var(--blue);background:#f2f7ff;padding:14px 16px;border-radius:8px}.warn{border-color:var(--amber);background:#fff8eb}.ok{border-color:var(--green);background:#effbf4}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;border-bottom:1px solid var(--line);padding:11px 9px}th{color:#41516b;background:#f7f9fc;position:sticky;top:0}code{background:#edf2f8;border-radius:5px;padding:2px 5px;color:#24466e}.tag{display:inline-block;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:650;white-space:nowrap}.full{background:#e6f6ec;color:#116436}.partial{background:#fff0d5;color:#875000}.extra{background:#e8f1ff;color:#1854a5}.fixed{background:#f1eaff;color:#6338a5}
|
||||
.toolbar{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0}.toolbar input,.toolbar select{border:1px solid #bdcada;border-radius:10px;padding:9px 11px;background:#fff;min-width:min(220px,100%);max-width:100%;flex:1 1 220px}.matrix{max-height:620px;overflow:auto;border:1px solid var(--line);border-radius:12px}.two{display:grid;grid-template-columns:1fr 1fr;gap:18px}.small{color:var(--muted);font-size:13px}ul{padding-left:20px}.footer{color:var(--muted);text-align:center;margin-top:22px}@media(max-width:850px){.grid,.two{grid-template-columns:1fr 1fr}.hero{padding:25px}}@media(max-width:560px){.grid,.two{grid-template-columns:1fr}main{width:min(100% - 18px,1180px)}.card{padding:18px}h1{font-size:28px}}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<header class="hero">
|
||||
<h1>DWS Wiki Shortcut 全景评审</h1>
|
||||
<p class="lead">以 13 项成熟 Wiki 用户任务为基线,重新审视 DWS 的空间、成员、节点与动态能力。本次不是按命令名凑数:每个入口都要求真实业务证据,缺失数组、畸形响应、空确认或读回不一致一律失败。</p>
|
||||
<div class="meta"><span class="pill">评审日期 2026-08-14</span><span class="pill">独立 worktree / 独立分支</span><span class="pill">真实组织数据 E2E 28/28</span><span class="pill">报告已去标识化</span></div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card metric"><b>20</b><span>公开 Wiki Shortcuts</span></div>
|
||||
<div class="card metric"><b>13/13</b><span>基线用户任务有对应路径</span></div>
|
||||
<div class="card metric"><b>7</b><span>DWS 额外场景</span></div>
|
||||
<div class="card metric"><b>20/20</b><span>真实数据能力已触达</span></div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>结论先行</h2>
|
||||
<div class="callout ok"><strong>DWS 已形成比“API 快捷别名”更完整的 Wiki 任务层。</strong> 基线中的 13 个用户任务均有对应入口;DWS 还提供空间搜索/详情/唯一解析、成员角色更新、库内节点搜索、协作动态和按空间名新建文档。创建、复制、移动等关键写能力从“请求发出”升级为“终态 + ID + 读回”成功标准。</div>
|
||||
<div class="callout warn" style="margin-top:12px"><strong>能力边界必须诚实表达。</strong> DingTalk 成员接口不提供游标,单次真实上限是 50,因此不能实现成员 <code>--page-all</code>;成员身份只接受同组织可用的 userId,无法提供 email/open_id 等多种身份模式;节点创建也没有等价的 origin/shortcut 模式。这些差异保留为明确边界,而不是用本地循环或空结果伪装。</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>13 项基线任务逐条映射</h2>
|
||||
<div class="matrix"><table><thead><tr><th>基线任务</th><th>DWS 主入口</th><th>结论</th><th>DWS 视角与边界</th></tr></thead><tbody>
|
||||
<tr><td><code>+space-list</code></td><td><code>wiki +space-list</code></td><td><span class="tag full">完整对齐</span></td><td>严格空集合、游标续传、自动翻页、停滞检测;支持组织/我的知识库。</td></tr>
|
||||
<tr><td><code>+space-create</code></td><td><code>wiki +space-create</code></td><td><span class="tag full">超过</span></td><td>公开真实 32 字符名称上限;创建后按 workspaceId 读回。</td></tr>
|
||||
<tr><td><code>+delete-space</code></td><td><code>wiki +delete-space</code></td><td><span class="tag full">超过</span></td><td>预读目标、高风险确认、只接受 <code>success=true</code>;兼容 <code>+space-delete</code>。</td></tr>
|
||||
<tr><td><code>+member-add</code></td><td><code>wiki +member-add</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 1–30 个 userId 与四种角色;以写接口终态作为成功证据,不把最多 50 条的名单误作精确读回。</td></tr>
|
||||
<tr><td><code>+member-list</code></td><td><code>wiki +member-list</code></td><td><span class="tag partial">任务对齐</span></td><td>严格成员数组、角色过滤、真实上限 50;后端无游标,不能提供诚实的 page-all。</td></tr>
|
||||
<tr><td><code>+member-remove</code></td><td><code>wiki +member-remove</code></td><td><span class="tag partial">任务对齐</span></td><td>支持批量 userId;只接受写接口明确终态,并公开无法进行精确成员读回的边界。</td></tr>
|
||||
<tr><td><code>+node-list</code></td><td><code>wiki +node-list</code></td><td><span class="tag full">完整对齐</span></td><td>正确跨域路由 doc/list_nodes,严格空目录、分页与自动翻页。</td></tr>
|
||||
<tr><td><code>+node-get</code></td><td><code>wiki +node-get</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 DingTalk 节点 ID/在线文档 URL 并返回文档域元数据;不接受跨平台专用的 token/type 组合。</td></tr>
|
||||
<tr><td><code>+node-create</code></td><td><code>wiki +node-create</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 adoc/axls/able/appt/adraw/amind/folder 并读回;无 origin/shortcut 等价接口。</td></tr>
|
||||
<tr><td><code>+node-copy</code></td><td><code>wiki +node-copy</code></td><td><span class="tag full">超过</span></td><td>确认后要求新 nodeId 并读取副本;底层面向在线节点,不把 .dlink 当独立副本。</td></tr>
|
||||
<tr><td><code>+move</code></td><td><code>wiki +move</code></td><td><span class="tag partial">任务对齐</span></td><td>同一入口支持 Wiki 内移动和“我的文档”在线节点入 Wiki,读回 workspace/folder;底层接口没有 apply 权限迁移开关。</td></tr>
|
||||
<tr><td><code>+move-to-drive</code></td><td><code>wiki +move-to-drive</code></td><td><span class="tag full">超过</span></td><td>DWS 当前接口同步完成并读回 workspace 变化,无需暴露异步 task 轮询。</td></tr>
|
||||
<tr><td><code>+node-delete</code></td><td><code>wiki +node-delete</code></td><td><span class="tag full">超过</span></td><td>预读并核对 workspace,高风险确认,要求删除终态。</td></tr>
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>DWS 可挖掘的 7 个额外场景</h2>
|
||||
<div class="two">
|
||||
<div><h3>定位与创建链</h3><ul><li><code>+space-search</code>:严格关键词搜索。</li><li><code>+space-get</code>:空间详情与 workspaceId 证据。</li><li><code>+resolve-space</code>:唯一命中直出 ID,多命中拒绝猜测。</li><li><code>+wiki-new-doc</code>:空间名解析 → 创建 → 文档读回。</li></ul></div>
|
||||
<div><h3>治理与巡检链</h3><ul><li><code>+member-update</code>:角色变更终态与不可精确读回声明。</li><li><code>+node-search</code>:库内关键词/扩展名搜索,严格零命中。</li><li><code>+feed-list</code>:知识库动态时间线与服务端 exclude-file 过滤。</li></ul></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>隐藏问题与修复</h2>
|
||||
<table><thead><tr><th>原问题</th><th>错误风险</th><th>本次修复</th></tr></thead><tbody>
|
||||
<tr><td>5 个旧 Wiki Shortcut 可直接执行,但只有 1 个进入公开目录。</td><td>Help、Schema、Skill 发现链与运行面漂移。</td><td><span class="tag fixed">20 项统一评审</span> 全部具备 Contract/Safety/Result 与语义目录记录。</td></tr>
|
||||
<tr><td>列表投影找不到数组或遇到坏元素时返回空 slice。</td><td>把内部错误、字段漂移误报为“没有数据”。</td><td><span class="tag fixed">失败关闭</span> 只有响应中真实存在的 <code>[]</code> 才是合法空集合。</td></tr>
|
||||
<tr><td>节点列表 Shortcut 调错 Wiki MCP 服务。</td><td>真实后端 <code>success=false</code>,Mock/静态检查看不出。</td><td><span class="tag fixed">跨域路由</span> 明确调用 doc/list_nodes,并纳入真实 E2E。</td></tr>
|
||||
<tr><td>成员帮助宣称最大 200。</td><td>真实接口超过 50 直接参数错误。</td><td><span class="tag fixed">真实上限</span> Shortcut 与原子 Help 均改为 50,并在本地提前拒绝。</td></tr>
|
||||
<tr><td>成员写操作从最多 50 条、不可分页的名单推断成员存在或缺失。</td><td>目标在截断部分时会误报写失败,或把未验证的移除报告为已读回。</td><td><span class="tag fixed">终态证据</span> 只接受写接口 <code>success=true</code>,并在结果中明确 <code>readbackAvailable=false</code>。</td></tr>
|
||||
<tr><td>空间搜索的稳定工作流属性名与实际请求属性名不同。</td><td>直接改写已发布的 <code>query/limit</code> 会造成无版本 Schema 破坏;继续隐式转换又会让审计者误以为请求同名透传。</td><td><span class="tag fixed">显式复合适配</span> 最终 Schema 保留兼容属性并明确声明转换为 <code>keyword/pageSize</code>;回归测试同时锁定最终交付和精确请求参数。</td></tr>
|
||||
<tr><td>知识库名称帮助宣称最大 100。</td><td>真实接口超过 32 失败。</td><td><span class="tag fixed">真实上限</span> Help 与 Shortcut 校验统一为 32。</td></tr>
|
||||
<tr><td>复制/移动/创建只把无异常视为成功。</td><td>空确认、未知远端效果或移动未到目标仍可能被接受。</td><td><span class="tag fixed">读回证明</span> 在后端具备精确查询能力时检查 success、业务 ID、workspace/folder 等最终状态。</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>真实数据 E2E 证据矩阵</h2>
|
||||
<p class="small">28 项业务断言全部通过。测试使用一次性空知识库、临时在线文档与一名同组织内部测试成员;所有对象在 finally 清理。报告不保存对象 ID、成员身份、组织信息、URL、trace 或原始响应。</p>
|
||||
<div class="toolbar"><input id="q" placeholder="筛选命令或证据"><select id="g"><option value="">全部分组</option><option>空间</option><option>成员</option><option>节点</option><option>动态</option></select></div>
|
||||
<div class="matrix"><table id="catalog"><thead><tr><th>分组</th><th>Shortcut</th><th>实际业务断言</th><th>状态</th></tr></thead><tbody>
|
||||
<tr><td>空间</td><td><code>+space-list</code></td><td>真实 count、hasMore、nextCursor;自动翻页返回两页结果。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+space-search</code></td><td>等待搜索索引后命中一次性 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+space-get</code></td><td>读回 workspaceId 与创建结果一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+resolve-space</code></td><td>唯一名称解析为同一 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+space-create</code></td><td>success=true、workspaceId 非空、详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+delete-space</code></td><td>目标预读、确认、success=true;兼容别名执行 finally 清理。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-list</code></td><td>真实 owner 条目与显式 members 数组,limit=50。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-add</code></td><td>命令只报告写终态;一次性小规模空间另行确认名单完整且角色为 READER。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-update</code></td><td>命令只报告写终态;一次性小规模空间另行确认角色变为 EDITOR。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-remove</code></td><td>命令只报告写终态;一次性小规模空间另行确认完整名单中不存在该 userId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-list</code></td><td>空库返回真实 nodes:[];有数据时验证游标与自动翻页。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-get</code></td><td>读回 nodeId 与请求一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-search</code></td><td>等待索引后按标题命中真实 nodeId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-create</code></td><td>分别创建 folder/adoc,均取得 nodeId 和元数据读回。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-copy</code></td><td>取得不同的新 nodeId,副本元数据可读。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+move</code></td><td>读回 workspaceId 与 folderId 均等于目标;兼容 +node-move。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+move-to-drive</code></td><td>移动后读回 workspace 发生变化,再通过 +move 移回。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-delete</code></td><td>目标预读与 workspace 核对后收到 success=true。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+wiki-new-doc</code></td><td>按唯一空间名创建,nodeId 与文档详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>动态</td><td><code>+feed-list</code></td><td>创建/移动操作后返回真实 feeds 数组,缺字段不会被接受。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
</tbody></table></div>
|
||||
<p class="small">可复跑入口:<code>make build</code> 后设置临时 <code>DWS_WIKI_E2E_MEMBER_ID</code>,在交互终端运行 <code>./scripts/dev/wiki-shortcut-e2e.py</code>。脚本只输出能力标签,不输出业务对象;受保护操作及最终清理均由命令逐项获取终端确认,非交互环境会在创建测试数据前拒绝运行。</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>成功判定与发布门</h2>
|
||||
<div class="two"><div><h3>运行时证据层</h3><ol><li>传输/MCP 调用成功。</li><li>响应契约存在且类型正确。</li><li>写操作必须有 <code>success=true</code>;创建类操作还必须有业务 ID。</li><li>后端具备精确查询时必须读回;不具备时明确发布不可读回,而非从截断集合推断。</li><li>集合只有显式数组才允许为空。</li></ol></div><div><h3>交付门</h3><ol><li>20/20 语义目录与注册面精确覆盖。</li><li>Contract、Safety、Result、统一输出完整。</li><li>生成漂移、Schema、确认真值、全量 Go 测试。</li><li>独立真实数据 E2E 与 finally 清理。</li><li>diff PII/密钥/本地绝对路径扫描。</li></ol></div></div>
|
||||
</section>
|
||||
<p class="footer">DWS Wiki Shortcut business review · sanitized engineering artifact</p>
|
||||
</main>
|
||||
<script>
|
||||
const q=document.querySelector('#q'),g=document.querySelector('#g'),rows=[...document.querySelectorAll('#catalog tbody tr')];
|
||||
function filter(){const text=q.value.trim().toLowerCase(),group=g.value;rows.forEach(r=>{const okText=!text||r.textContent.toLowerCase().includes(text),okGroup=!group||r.children[0].textContent===group;r.style.display=okText&&okGroup?'':'none'})}q.addEventListener('input',filter);g.addEventListener('change',filter);
|
||||
</script></body></html>
|
||||
@@ -2,6 +2,8 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
|
||||
go 1.25.9
|
||||
|
||||
replace gitlab.alibaba-inc.com/aes/aem-go-sdk => ./third_party/aem-go-sdk
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
@@ -17,6 +19,7 @@ require (
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
gitlab.alibaba-inc.com/aes/aem-go-sdk v0.3.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/text v0.35.0
|
||||
|
||||
@@ -268,7 +268,7 @@ type frameworkFailWriter struct{}
|
||||
|
||||
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
@@ -470,6 +470,118 @@ func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteDeterministicInterruptionBranches(t *testing.T) {
|
||||
install := func(t *testing.T, state *processSignalState, stdout, stderr io.Writer) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootInstallProcessSignalContext, func(ctx context.Context, _ *output.ResultStore) (context.Context, *processSignalState, func()) {
|
||||
return ctx, state, func() {}
|
||||
})
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
return cmd
|
||||
})
|
||||
}
|
||||
interrupted := func(primaryCompleted bool) *processSignalState {
|
||||
return &processSignalState{
|
||||
interruption: &processInterruption{signal: os.Interrupt},
|
||||
primaryCompletedAtSignal: primaryCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("preparse interruption emits unified failure", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
install(t, interrupted(false), &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("preparse failed") })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) {
|
||||
t.Fatal("preparse failure reached command execution")
|
||||
return nil, nil
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("preparse interruption = code %d summary %q stdout %q", code, summary, stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interruption before emission becomes primary error", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
install(t, interrupted(false), &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil })
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("pre-emission interruption = code %d summary %q stdout %q", code, summary, stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("late hook error preserves emitted result", func(t *testing.T) {
|
||||
install(t, interrupted(true), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, errors.New("late hook failed")
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary != "late hook failed" {
|
||||
t.Fatalf("late hook result = code %d summary %q", code, summary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interruption after emission preserves emitted result", func(t *testing.T) {
|
||||
install(t, interrupted(false), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, nil
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary == "" {
|
||||
t.Fatalf("post-emission interruption = code %d summary %q", code, summary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication failure replaces unobservable result", func(t *testing.T) {
|
||||
var original bytes.Buffer
|
||||
install(t, interrupted(false), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{file: file, original: &original, finished: true}))
|
||||
publicationErr := newOutputPublicationError("publish", errors.New("rename failed"))
|
||||
if _, handled, emitErr := emitOutputPublicationFailure(cmd, publicationErr); !handled || emitErr != nil {
|
||||
t.Fatalf("precondition publication failure = handled %v error %v unified %v state %v", handled, emitErr, output.UsesUnifiedResult(cmd), outputSinkForCommand(cmd) != nil)
|
||||
}
|
||||
return cmd, publicationErr
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 5 || summary == "" {
|
||||
t.Fatalf("publication failure = code %d summary %q output %q", code, summary, original.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type frameworkPanicWriter struct{}
|
||||
|
||||
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
|
||||
|
||||
@@ -66,6 +66,32 @@ func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePreparseProfileFlagUsesLastOccurrence(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{name: "space then equals", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, want: "corp-b", valid: true},
|
||||
{name: "equals then space", args: []string{"--profile=corp-a", "version", "--profile", "corp-b"}, want: "corp-b", valid: true},
|
||||
{name: "last multi", args: []string{"--profile=corp-a", "--profile", "corp-b,", "corp-c", "version"}, want: "corp-b,corp-c", valid: true},
|
||||
{name: "empty equals clears earlier", args: []string{"--profile=corp-a", "version", "--profile="}},
|
||||
{name: "missing value clears earlier", args: []string{"--profile=corp-a", "version", "--profile"}},
|
||||
{name: "next flag is not profile value", args: []string{"--profile=corp-a", "--profile", "--debug", "version"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := preparseProfileFlag(tc.args); got != tc.want {
|
||||
t.Fatalf("preparseProfileFlag(%#v) = %q, want %q", tc.args, got, tc.want)
|
||||
}
|
||||
_, specified, valid := preparseProfileSelection(tc.args)
|
||||
if !specified || valid != tc.valid {
|
||||
t.Fatalf("preparseProfileSelection(%#v) = specified %v valid %v, want true/%v", tc.args, specified, valid, tc.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
+101
-17
@@ -80,10 +80,19 @@ var (
|
||||
rootAuthLoadTokenData = authpkg.LoadTokenData
|
||||
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
|
||||
rootEmitResult = output.EmitResult
|
||||
rootInstallProcessSignalContext = installProcessSignalContext
|
||||
)
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() (exitCode int) {
|
||||
func Execute() int {
|
||||
exitCode, _, _ := ExecuteWithTelemetry()
|
||||
return exitCode
|
||||
}
|
||||
|
||||
// ExecuteWithTelemetry runs the root command and additionally returns a
|
||||
// privacy-safe command path and error summary for the official CLI entrypoint.
|
||||
func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage string) {
|
||||
commandPath = "dws"
|
||||
var (
|
||||
root *cobra.Command
|
||||
executed *cobra.Command
|
||||
@@ -91,12 +100,16 @@ func Execute() (exitCode int) {
|
||||
)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errorMessage = "internal panic"
|
||||
target := executed
|
||||
if target == nil && root != nil {
|
||||
if found, _, err := root.Find(os.Args[1:]); err == nil {
|
||||
target = found
|
||||
}
|
||||
}
|
||||
if target != nil {
|
||||
commandPath = telemetryCommandPath(target)
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
exitCode = code
|
||||
if target != nil {
|
||||
@@ -121,6 +134,7 @@ func Execute() (exitCode int) {
|
||||
CloseFileLogger()
|
||||
if executed != nil {
|
||||
if err := closeOutputSink(executed); err != nil {
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
|
||||
exitCode = code
|
||||
} else {
|
||||
@@ -144,7 +158,9 @@ func Execute() (exitCode int) {
|
||||
agentMetadata := readAgentMetadataSnapshot()
|
||||
if err := agentMetadata.validationError(); err != nil {
|
||||
emitEarlyAgentMetadataValidationError(err, os.Args[1:])
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
|
||||
timing := NewTimingCollector()
|
||||
@@ -162,12 +178,13 @@ func Execute() (exitCode int) {
|
||||
ctx, resultStore = output.WithResultStore(ctx)
|
||||
var signalState *processSignalState
|
||||
var stopSignals func()
|
||||
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
|
||||
ctx, signalState, stopSignals = rootInstallProcessSignalContext(ctx, resultStore)
|
||||
defer stopSignals()
|
||||
|
||||
initStart := time.Now()
|
||||
engine := newPipelineEngine()
|
||||
root = rootNewRootCommandWithEngine(ctx, engine)
|
||||
commandPath = telemetryCommandPath(root)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
@@ -182,15 +199,23 @@ func Execute() (exitCode int) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(target, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
commandPath = telemetryCommandPathForArgs(root, os.Args[1:])
|
||||
|
||||
var err error
|
||||
executed, err = rootExecuteCommand(root)
|
||||
if executed != nil {
|
||||
commandPath = telemetryCommandPath(executed)
|
||||
}
|
||||
// PersistentPostRunE normally commits or aborts the transactional output
|
||||
// sink. Finalize once more at the process boundary so custom execution
|
||||
// seams, embedding callers, or future hook changes cannot leave publication
|
||||
@@ -222,7 +247,9 @@ func Execute() (exitCode int) {
|
||||
// successfully emitted result into a contradictory 130/143 process
|
||||
// status; likewise, a failed publication must retain its internal
|
||||
// error code instead of being relabelled as cancellation.
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(interrupted)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
}
|
||||
var publicationErr *outputPublicationError
|
||||
@@ -233,20 +260,26 @@ func Execute() (exitCode int) {
|
||||
if err != nil {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
commandPath = telemetryCommandPath(root)
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
var publicationErr *outputPublicationError
|
||||
if stderrors.As(err, &publicationErr) {
|
||||
errorMessage = telemetryErrorSummary(publicationErr)
|
||||
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
|
||||
if emitErr == nil {
|
||||
return failureCode
|
||||
exitCode = failureCode
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
|
||||
}
|
||||
return apperrors.ExitCode(publicationErr)
|
||||
exitCode = apperrors.ExitCode(publicationErr)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
var raw apperrors.RawStderrError
|
||||
@@ -254,7 +287,9 @@ func Execute() (exitCode int) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(executed, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||
}
|
||||
@@ -264,12 +299,42 @@ func Execute() (exitCode int) {
|
||||
_, _ = fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(resultStore); emitted {
|
||||
return code
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
return 0
|
||||
return
|
||||
}
|
||||
|
||||
func telemetryCommandPath(command *cobra.Command) string {
|
||||
if command == nil {
|
||||
return "dws"
|
||||
}
|
||||
path := strings.TrimSpace(command.CommandPath())
|
||||
root := command.Root()
|
||||
rootName := strings.TrimSpace(root.Name())
|
||||
if path == rootName {
|
||||
return rootName
|
||||
}
|
||||
if rootName != "" {
|
||||
path = strings.TrimSpace(strings.TrimPrefix(path, rootName+" "))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func telemetryCommandPathForArgs(root *cobra.Command, args []string) string {
|
||||
if root == nil {
|
||||
return "dws"
|
||||
}
|
||||
command, _, err := root.Find(args)
|
||||
if err != nil || command == nil {
|
||||
return telemetryCommandPath(root)
|
||||
}
|
||||
return telemetryCommandPath(command)
|
||||
}
|
||||
|
||||
// emitEarlyAgentMetadataValidationError preserves each built-in command's
|
||||
@@ -959,17 +1024,36 @@ func installReviewedFlagProtectionHandlers(root *cobra.Command) {
|
||||
}
|
||||
|
||||
func preparseProfileFlag(args []string) string {
|
||||
profile, _, valid := preparseProfileSelection(args)
|
||||
if !valid {
|
||||
return ""
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func preparseProfileSelection(args []string) (profile string, specified, valid bool) {
|
||||
args, _ = normalizeProfileFlagArgs(args)
|
||||
valid = true
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := strings.TrimSpace(args[i])
|
||||
switch {
|
||||
case arg == "--profile" && i+1 < len(args):
|
||||
return strings.TrimSpace(args[i+1])
|
||||
case arg == "--profile":
|
||||
specified = true
|
||||
if i+1 >= len(args) || strings.HasPrefix(strings.TrimSpace(args[i+1]), "-") {
|
||||
profile = ""
|
||||
valid = false
|
||||
continue
|
||||
}
|
||||
profile = strings.TrimSpace(args[i+1])
|
||||
valid = profile != ""
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
specified = true
|
||||
profile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
valid = profile != ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return profile, specified, valid
|
||||
}
|
||||
|
||||
func normalizeProcessProfileArgs() func() {
|
||||
|
||||
@@ -37,29 +37,64 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
var executedLeaf *cobra.Command
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
executedLeaf = &cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}
|
||||
sheet.AddCommand(executedLeaf)
|
||||
root.AddCommand(sheet)
|
||||
return root
|
||||
}
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("successful Execute code = %d", code)
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return executedLeaf, nil }
|
||||
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 0 || commandPath != "sheet read" || errorMessage != "" {
|
||||
t.Fatalf("successful ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
|
||||
}
|
||||
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return errors.New("alias/canonical conflict") }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("pre-parse conflict returned zero")
|
||||
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "alias/canonical conflict" {
|
||||
t.Fatalf("pre-parse conflict = code %d error %q", code, errorMessage)
|
||||
}
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
|
||||
wantErr := errors.New("unknown command missing")
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("failed Execute returned zero")
|
||||
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "unknown command" {
|
||||
t.Fatalf("failed ExecuteWithTelemetry = code %d error %q", code, errorMessage)
|
||||
}
|
||||
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") }
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("panic Execute code = %d", code)
|
||||
os.Args = []string{"dws", "sheet", "read"}
|
||||
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 5 || commandPath != "sheet read" || errorMessage != "internal panic" {
|
||||
t.Fatalf("panic ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryCommandPath(t *testing.T) {
|
||||
if got := telemetryCommandPath(nil); got != "dws" {
|
||||
t.Fatalf("nil command path = %q, want dws", got)
|
||||
}
|
||||
if got := telemetryCommandPathForArgs(nil, nil); got != "dws" {
|
||||
t.Fatalf("nil root command path = %q, want dws", got)
|
||||
}
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
read := &cobra.Command{Use: "read <range>"}
|
||||
sheet.AddCommand(read)
|
||||
root.AddCommand(sheet)
|
||||
if got := telemetryCommandPath(root); got != "dws" {
|
||||
t.Fatalf("root command path = %q, want dws", got)
|
||||
}
|
||||
if got := telemetryCommandPath(read); got != "sheet read" {
|
||||
t.Fatalf("leaf command path = %q, want sheet read", got)
|
||||
}
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
read.Aliases = []string{"get"}
|
||||
if got := telemetryCommandPathForArgs(root, []string{"--profile", "corp-a", "sheet", "get", "A1:B2"}); got != "sheet read" {
|
||||
t.Fatalf("pre-execution command path = %q, want sheet read", got)
|
||||
}
|
||||
if got := telemetryCommandPathForArgs(root, []string{"missing"}); got != "dws" {
|
||||
t.Fatalf("unknown pre-execution command path = %q, want dws", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
@@ -13,6 +14,12 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// feedbackFormURL points at the DingTalk Notable form collecting dws CLI
|
||||
// user-experience feedback. The source parameter tags submissions that
|
||||
// originated from the CLI help output so they can be told apart from
|
||||
// responses arriving through other channels.
|
||||
const feedbackFormURL = "https://alidocs.dingtalk.com/notable/share/form/v01eLbnj1bw1ELb0laN_dv19yqvsgs3oebp3pcjys_1qX0QQ0?source=dws-cli"
|
||||
|
||||
func configureRootHelp(root *cobra.Command) {
|
||||
if root == nil {
|
||||
return
|
||||
@@ -101,6 +108,29 @@ func renderRootHelp(root *cobra.Command) {
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, tui.Dim(long))
|
||||
}
|
||||
|
||||
// Keep the feedback entry last: everything above it is operational guidance
|
||||
// an agent acts on, while the survey is addressed to human readers who
|
||||
// scroll to the end.
|
||||
_, _ = fmt.Fprintln(w)
|
||||
renderRootFeedback(w)
|
||||
}
|
||||
|
||||
// renderRootFeedback prints the user-experience survey entry. The URL occupies
|
||||
// its own line and is never wrapped or padded through a tabwriter: it is longer
|
||||
// than the help rule width, and breaking it would stop terminals from
|
||||
// recognizing it as a clickable hyperlink. Soft wrapping performed by the
|
||||
// terminal itself keeps the link intact.
|
||||
//
|
||||
// The label is intentionally not routed through i18n. Everything surrounding it
|
||||
// in this listing — service descriptions, utility descriptions, global flag
|
||||
// usage — is hardcoded Chinese, so translating this one line would render it in
|
||||
// English on any host whose LANG is not zh_*, leaving a single English line
|
||||
// inside an otherwise Chinese screen.
|
||||
func renderRootFeedback(w io.Writer) {
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Feedback:"))
|
||||
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.Dim("使用体验反馈问卷(1 分钟)"))
|
||||
_, _ = fmt.Fprintf(w, " %s\n", tui.Cyan(feedbackFormURL))
|
||||
}
|
||||
|
||||
func renderRootGlobalFlags(root *cobra.Command) {
|
||||
|
||||
@@ -53,6 +53,49 @@ func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpShowsFeedbackEntry(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("root help: %v\n%s", err, out.String())
|
||||
}
|
||||
help := out.String()
|
||||
// The label stays Chinese regardless of the host locale: the rest of this
|
||||
// listing is hardcoded Chinese, so a translated label would show up as a
|
||||
// lone English line on any host whose LANG is not zh_*.
|
||||
for _, want := range []string{"Feedback:", "使用体验反馈问卷", feedbackFormURL} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("root help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
// The form URL is longer than the help rule width; it must stay on a
|
||||
// single unbroken line so terminals keep recognizing it as a hyperlink.
|
||||
if !strings.Contains(help, "\n "+feedbackFormURL+"\n") {
|
||||
t.Fatalf("feedback URL must occupy one unwrapped line:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
// The feedback entry is deliberately root-only: this CLI is driven mostly by
|
||||
// AI agents, and repeating a survey link in every subcommand help would be
|
||||
// pure context noise. Guard the boundary so a future refactor cannot move the
|
||||
// rendering into the shared subcommand help path unnoticed.
|
||||
func TestSubcommandHelpOmitsFeedbackEntry(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"chat", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat help: %v\n%s", err, out.String())
|
||||
}
|
||||
if help := out.String(); strings.Contains(help, feedbackFormURL) {
|
||||
t.Fatalf("subcommand help must not carry the feedback URL:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
|
||||
@@ -230,13 +230,20 @@ func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
t.Chdir(dir)
|
||||
// Keep argv portable: an absolute Windows path contains a volume colon,
|
||||
// which the CLI intentionally rejects as unsafe user-supplied output.
|
||||
target := "result.json"
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalInfo, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
@@ -277,7 +284,7 @@ func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
|
||||
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
|
||||
}
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertOutputFile(t, target, "original", originalInfo.Mode().Perm())
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 399
|
||||
publicShortcutCount = 418
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 401
|
||||
schemaPublishedShortcutCount = 420
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 399
|
||||
publiclyDeliveredShortcutCount = 418
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -140,6 +140,78 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search")
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("wiki +space-search interface_mode = %q, want composite", got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
for _, fragment := range []string{"query/limit", "search_wikiSpaces.keyword/pageSize", "versioned Schema migration"} {
|
||||
if !strings.Contains(reason, fragment) {
|
||||
t.Fatalf("wiki +space-search interface_reason = %q, want fragment %q", reason, fragment)
|
||||
}
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range map[string]string{"query": "query", "limit": "limit"} {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("wiki +space-search missing --%s parameter: %#v", name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("wiki +space-search --%s property = %q, want compatibility value %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
checked := 0
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service != "wiki" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
canonical := shortcutSchemaCanonical(declared)
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Fatalf("delivery schema --all is missing %s", canonical)
|
||||
}
|
||||
examples := schemaContractStringSlice(tool["examples"])
|
||||
if len(examples) == 0 {
|
||||
t.Fatalf("%s has no delivered examples", canonical)
|
||||
}
|
||||
for _, example := range examples {
|
||||
argv, err := cli.ParseAgentExampleArgv(example)
|
||||
if err != nil {
|
||||
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
|
||||
}
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Required {
|
||||
continue
|
||||
}
|
||||
names := append([]string{flag.Name}, flag.Aliases...)
|
||||
if !schemaExampleHasLongFlag(argv, names...) {
|
||||
t.Errorf("%s example %q is missing required --%s", canonical, example, flag.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 20 {
|
||||
t.Fatalf("checked Wiki shortcut examples = %d, want 20", checked)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
for _, argument := range argv {
|
||||
for _, name := range names {
|
||||
if argument == "--"+name || strings.HasPrefix(argument, "--"+name+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
|
||||
@@ -103,7 +103,7 @@ func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Wri
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
@@ -197,7 +197,7 @@ func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
@@ -229,7 +229,7 @@ func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
const maxTelemetryErrorRunes = 200
|
||||
|
||||
var (
|
||||
telemetryUnknownFlagPattern = regexp.MustCompile(`(?i)unknown flag:\s*(--[a-z0-9][a-z0-9-]*)`)
|
||||
telemetryAuthPattern = regexp.MustCompile(`(?i)\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+`)
|
||||
telemetrySensitiveFlag = regexp.MustCompile(`(?i)(--(?:access-token|refresh-token|token|client-secret|client-id|password|api-key|authorization|cookie|credential|secret))(?:=|\s+)\S+`)
|
||||
telemetrySensitiveValue = regexp.MustCompile(`(?i)\b(authorization|client[-_]?secret|client[-_]?id|access[-_]?token|refresh[-_]?token|api[-_]?key|password|cookie|credential|secret|token)\b\s*[:=]\s*[^\s,;]+`)
|
||||
telemetryURLPattern = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s]+`)
|
||||
telemetryJSONPattern = regexp.MustCompile(`(?s)[\[{].*[\]}]`)
|
||||
telemetryUnixPathPattern = regexp.MustCompile(`(^|[\s=:])(?:~/|/)[^\s]+`)
|
||||
telemetryWindowsPathPattern = regexp.MustCompile(`(?i)(^|[\s=])[a-z]:[\\/][^\s]+`)
|
||||
telemetryRelativePathPattern = regexp.MustCompile(`(^|[\s=:])\.\.?/[^\s]+`)
|
||||
telemetryEmailPattern = regexp.MustCompile(`(?i)\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b`)
|
||||
telemetryPhonePattern = regexp.MustCompile(`\b\+?\d[\d -]{7,}\d\b`)
|
||||
telemetryOpaqueTokenPattern = regexp.MustCompile(`\b[a-zA-Z0-9_-]{16,}\b`)
|
||||
)
|
||||
|
||||
func telemetryErrorSummary(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var patError *apperrors.PATError
|
||||
if stderrors.As(err, &patError) {
|
||||
return "permission error"
|
||||
}
|
||||
var rawError apperrors.RawStderrError
|
||||
if stderrors.As(err, &rawError) {
|
||||
return "raw stderr error"
|
||||
}
|
||||
if isUnknownCommandError(err) {
|
||||
return "unknown command"
|
||||
}
|
||||
if match := telemetryUnknownFlagPattern.FindStringSubmatch(err.Error()); len(match) == 2 {
|
||||
return "unknown flag: " + match[1]
|
||||
}
|
||||
return sanitizeTelemetryErrorText(err.Error())
|
||||
}
|
||||
|
||||
func telemetryPanicMessages(value any) (display, summary string) {
|
||||
return fmt.Sprintf("internal panic: %v", value), "internal panic"
|
||||
}
|
||||
|
||||
func sanitizeTelemetryErrorText(message string) string {
|
||||
message = output.SanitizeForTerminal(message)
|
||||
message = telemetryAuthPattern.ReplaceAllString(message, "<credential>")
|
||||
message = telemetrySensitiveFlag.ReplaceAllString(message, "$1=<redacted>")
|
||||
message = telemetrySensitiveValue.ReplaceAllString(message, "$1=<redacted>")
|
||||
message = telemetryURLPattern.ReplaceAllString(message, "<url>")
|
||||
message = telemetryJSONPattern.ReplaceAllString(message, "<payload>")
|
||||
message = redactTelemetryQuotedText(message)
|
||||
message = telemetryUnixPathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryWindowsPathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryRelativePathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryEmailPattern.ReplaceAllString(message, "<email>")
|
||||
message = telemetryPhonePattern.ReplaceAllString(message, "<phone>")
|
||||
message = telemetryOpaqueTokenPattern.ReplaceAllStringFunc(message, func(value string) string {
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
hasLetter = hasLetter || unicode.IsLetter(r)
|
||||
hasDigit = hasDigit || unicode.IsDigit(r)
|
||||
}
|
||||
if hasLetter && hasDigit {
|
||||
return "<id>"
|
||||
}
|
||||
return value
|
||||
})
|
||||
message = strings.Join(strings.Fields(message), " ")
|
||||
return truncateTelemetryText(message, maxTelemetryErrorRunes)
|
||||
}
|
||||
|
||||
func redactTelemetryQuotedText(message string) string {
|
||||
var result strings.Builder
|
||||
runes := []rune(message)
|
||||
for index := 0; index < len(runes); {
|
||||
quote := runes[index]
|
||||
if quote != '\'' && quote != '"' && quote != '`' {
|
||||
result.WriteRune(quote)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
result.WriteRune(quote)
|
||||
result.WriteString("<redacted>")
|
||||
index++
|
||||
escaped := false
|
||||
for index < len(runes) {
|
||||
current := runes[index]
|
||||
index++
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if current == '\\' && quote != '`' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if current == quote {
|
||||
result.WriteRune(quote)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func truncateTelemetryText(message string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(message)
|
||||
if len(runes) <= maxRunes {
|
||||
return message
|
||||
}
|
||||
if maxRunes <= 3 {
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
return string(runes[:maxRunes-3]) + "..."
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
type telemetryRawError string
|
||||
|
||||
func (e telemetryRawError) Error() string { return string(e) }
|
||||
func (e telemetryRawError) RawStderr() string { return string(e) }
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorSummaryFixedFamilies(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "nil", want: ""},
|
||||
{name: "PAT", err: &apperrors.PATError{RawJSON: `{"token":"secret"}`}, want: "permission error"},
|
||||
{name: "raw stderr", err: telemetryRawError("raw secret"), want: "raw stderr error"},
|
||||
{name: "unknown command", err: errors.New(`unknown command "secret-value" for "dws"`), want: "unknown command"},
|
||||
{name: "unknown flag", err: errors.New("unknown flag: --token=secret-value"), want: "unknown flag: --token"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := telemetryErrorSummary(tc.err); got != tc.want {
|
||||
t.Fatalf("telemetryErrorSummary() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSanitizeTelemetryErrorText(t *testing.T) {
|
||||
message := "\x1b[31mfailed\x1b[0m " +
|
||||
"--client-secret very-secret " +
|
||||
"--access-token access-secret " +
|
||||
"Authorization: Bearer abcdefghijklmnop1234 " +
|
||||
"url=https://example.test/path?token=secret " +
|
||||
`body={"access_token":"secret"} ` +
|
||||
`user="Alice" email=alice@example.test phone=13800138000 ` +
|
||||
"path=/Users/alice/private.txt relative=./private/secrets.txt id=abcDEF1234567890XYZ"
|
||||
got := sanitizeTelemetryErrorText(message)
|
||||
for _, secret := range []string{
|
||||
"very-secret", "access-secret", "abcdefghijklmnop1234", "example.test", "access_token",
|
||||
"Alice", "alice@example.test", "13800138000", "/Users/alice", "abcDEF1234567890XYZ", "\x1b",
|
||||
"./private/secrets.txt",
|
||||
} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("sanitized telemetry error leaked %q: %q", secret, got)
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"failed", "<redacted>", "<url>", "<payload>", "<path>", "<id>"} {
|
||||
if !strings.Contains(got, marker) {
|
||||
t.Fatalf("sanitized telemetry error missing %q: %q", marker, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorTruncationAndPanic(t *testing.T) {
|
||||
message := strings.Repeat("错", maxTelemetryErrorRunes+1)
|
||||
got := sanitizeTelemetryErrorText(message)
|
||||
if len([]rune(got)) != maxTelemetryErrorRunes || !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("truncated telemetry error rune length = %d suffix = %q", len([]rune(got)), got[len(got)-3:])
|
||||
}
|
||||
display, summary := telemetryPanicMessages("token-secret")
|
||||
if display != "internal panic: token-secret" || summary != "internal panic" || strings.Contains(summary, "token-secret") {
|
||||
t.Fatalf("panic messages = display %q summary %q", display, summary)
|
||||
}
|
||||
if got := truncateTelemetryText("value", 0); got != "" {
|
||||
t.Fatalf("zero-limit truncation = %q", got)
|
||||
}
|
||||
if got := truncateTelemetryText("value", 3); got != "val" {
|
||||
t.Fatalf("short-limit truncation = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorEscapesAndOpaqueWords(t *testing.T) {
|
||||
const opaqueWord = "abcdefghijklmnop"
|
||||
got := sanitizeTelemetryErrorText(`failed "quoted \"value" ` + opaqueWord)
|
||||
if strings.Contains(got, "value") || !strings.Contains(got, opaqueWord) {
|
||||
t.Fatalf("escaped quote sanitization = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// TelemetryIdentity is the privacy-reviewed subset of the local authentication
|
||||
// record that may be attached to a CLI execution event.
|
||||
type TelemetryIdentity struct {
|
||||
UserID string
|
||||
UserName string
|
||||
CorpID string
|
||||
}
|
||||
|
||||
var telemetryResolveProfileMetadata = authpkg.ResolveProfileMetadataReadOnly
|
||||
|
||||
// ResolveTelemetryIdentity returns a pre-execution snapshot of the identity
|
||||
// selected by args. Multi-profile executions are attributed to the current
|
||||
// default profile. Resolution is deliberately best-effort: telemetry must not
|
||||
// refresh credentials or change command behavior when local auth data is
|
||||
// missing, invalid, or unreadable.
|
||||
func ResolveTelemetryIdentity(args []string) (identity TelemetryIdentity) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
identity = TelemetryIdentity{}
|
||||
}
|
||||
}()
|
||||
|
||||
selector, specified, valid := preparseProfileSelection(args)
|
||||
if specified && !valid {
|
||||
return TelemetryIdentity{}
|
||||
}
|
||||
profile, err := resolveTelemetryProfileMetadata(defaultConfigDir(), selector)
|
||||
if err != nil || profile == nil {
|
||||
return TelemetryIdentity{}
|
||||
}
|
||||
return TelemetryIdentity{
|
||||
UserID: strings.TrimSpace(profile.UserID),
|
||||
UserName: strings.TrimSpace(profile.UserName),
|
||||
CorpID: strings.TrimSpace(profile.CorpID),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTelemetryProfileMetadata(configDir, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" || !strings.Contains(selector, ",") {
|
||||
return telemetryResolveProfileMetadata(configDir, selector)
|
||||
}
|
||||
|
||||
// A local profile name may itself contain a comma. Match the runtime
|
||||
// resolver by trying the full selector before interpreting it as CSV.
|
||||
if profile, err := telemetryResolveProfileMetadata(configDir, selector); err == nil && profile != nil {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(selector, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil, fmt.Errorf("--profile contains an empty profile selector: %q", selector)
|
||||
}
|
||||
profile, err := telemetryResolveProfileMetadata(configDir, part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", part)
|
||||
}
|
||||
}
|
||||
return telemetryResolveProfileMetadata(configDir, "")
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityProfileSelection(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
profiles := map[string]*authpkg.ProfileMetadata{
|
||||
"": {UserID: " default-user ", UserName: " Default User ", CorpID: " default-corp "},
|
||||
"corp-a": {UserID: "user-a", UserName: "Alice", CorpID: "corp-a"},
|
||||
"corp-b": {UserID: "user-b", UserName: "Bob", CorpID: "corp-b"},
|
||||
"alpha,beta": {UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"},
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
profiles map[string]*authpkg.ProfileMetadata
|
||||
wantCalls []string
|
||||
want TelemetryIdentity
|
||||
}{
|
||||
{name: "default profile", args: []string{"version"}, profiles: profiles, wantCalls: []string{""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
{name: "single profile", args: []string{"--profile", "corp-a", "version"}, profiles: profiles, wantCalls: []string{"corp-a"}, want: TelemetryIdentity{UserID: "user-a", UserName: "Alice", CorpID: "corp-a"}},
|
||||
{name: "equals form after command", args: []string{"version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
|
||||
{name: "last repeated profile", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
|
||||
{name: "comma profile name", args: []string{"--profile", "alpha,beta", "version"}, profiles: profiles, wantCalls: []string{"alpha,beta"}, want: TelemetryIdentity{UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"}},
|
||||
{name: "multi profile uses default", args: []string{"--profile", "corp-a,corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
{name: "unquoted multi profile", args: []string{"--profile", "corp-a,", "corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var calls []string
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(configDir, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
if configDir != "/telemetry-config" {
|
||||
t.Fatalf("resolver config dir = %q", configDir)
|
||||
}
|
||||
calls = append(calls, selector)
|
||||
profile := tc.profiles[selector]
|
||||
if profile == nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
clone := *profile
|
||||
return &clone, nil
|
||||
})
|
||||
|
||||
if got := ResolveTelemetryIdentity(tc.args); got != tc.want {
|
||||
t.Fatalf("ResolveTelemetryIdentity() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
if !reflect.DeepEqual(calls, tc.wantCalls) {
|
||||
t.Fatalf("metadata selectors = %#v, want %#v", calls, tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsMissingProfileValue(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"version", "--profile"},
|
||||
{"--profile=corp-a", "version", "--profile="},
|
||||
{"--profile", "--debug", "version"},
|
||||
} {
|
||||
t.Run(strings.Join(args, "_"), func(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(string, string) (*authpkg.ProfileMetadata, error) {
|
||||
t.Fatal("invalid profile syntax attempted metadata resolution")
|
||||
return nil, nil
|
||||
})
|
||||
if got := ResolveTelemetryIdentity(args); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("invalid profile identity = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityFailsClosed(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
fail := errors.New("metadata unavailable")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resolve func(string, string) (*authpkg.ProfileMetadata, error)
|
||||
}{
|
||||
{name: "read error", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, fail }},
|
||||
{name: "missing profile", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, nil }},
|
||||
{name: "empty fields", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return &authpkg.ProfileMetadata{}, nil }},
|
||||
{name: "resolver panic", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { panic("metadata failure") }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, tc.resolve)
|
||||
if got := ResolveTelemetryIdentity(nil); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("failed-closed identity = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsInvalidMultiProfile(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
if selector == "corp-a" {
|
||||
return &authpkg.ProfileMetadata{UserID: "user-a", CorpID: "corp-a"}, nil
|
||||
}
|
||||
return nil, errors.New("profile not found")
|
||||
})
|
||||
if got := ResolveTelemetryIdentity([]string{"--profile", "corp-a,missing", "version"}); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("invalid multi-profile identity = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryProfileMetadataRejectsMalformedMulti(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
switch selector {
|
||||
case "corp-a,,corp-b":
|
||||
return nil, errors.New("not a literal profile")
|
||||
case "corp-a":
|
||||
return &authpkg.ProfileMetadata{UserID: "user-a"}, nil
|
||||
case "missing":
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, errors.New("unexpected selector")
|
||||
}
|
||||
})
|
||||
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,,corp-b"); err == nil || !strings.Contains(err.Error(), "empty profile selector") {
|
||||
t.Fatalf("empty multi-profile selector error = %v", err)
|
||||
}
|
||||
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,missing"); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("missing multi-profile selector error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProfileMetadata is the minimal, non-sensitive identity projection exposed to
|
||||
// telemetry callers. It intentionally excludes profile names, client IDs,
|
||||
// organization names, token material, and credential status.
|
||||
type ProfileMetadata struct {
|
||||
UserID string
|
||||
UserName string
|
||||
CorpID string
|
||||
}
|
||||
|
||||
// ResolveProfileMetadataReadOnly resolves one identity exclusively from the
|
||||
// non-sensitive profiles.json metadata. It deliberately avoids auth locks,
|
||||
// token stores, Keychain access, migrations, quarantine renames, and writes.
|
||||
// A missing metadata file or an empty current profile returns (nil, nil).
|
||||
func ResolveProfileMetadataReadOnly(configDir, selector string) (*ProfileMetadata, error) {
|
||||
data, err := profilesReadFile(ProfilesPath(configDir))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read profile metadata: %w", err)
|
||||
}
|
||||
|
||||
var cfg ProfilesConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse profile metadata: %w", err)
|
||||
}
|
||||
if cfg.Version > profilesMaxVersion {
|
||||
return nil, fmt.Errorf("profile metadata version %d is newer than supported version %d", cfg.Version, profilesMaxVersion)
|
||||
}
|
||||
normalizeProfilesConfig(&cfg)
|
||||
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
selector = strings.TrimSpace(cfg.CurrentProfile)
|
||||
if selector == "" {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
profile, _, err := resolveProfileSelection("", &cfg, selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProfileMetadata{
|
||||
UserID: profile.UserID,
|
||||
UserName: profile.UserName,
|
||||
CorpID: profile.CorpID,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnly(t *testing.T) {
|
||||
const metadata = `{
|
||||
"version": 3,
|
||||
"currentProfile": "corp-a:user-a",
|
||||
"profiles": [
|
||||
{"name":"alpha","corpId":"corp-a","userId":"user-a","userName":"Alice"},
|
||||
{"name":"beta","corpId":"corp-b","userId":"user-b","userName":"Bob"}
|
||||
]
|
||||
}`
|
||||
reads := 0
|
||||
testseam.Swap(t, &profilesReadFile, func(path string) ([]byte, error) {
|
||||
reads++
|
||||
if !strings.HasSuffix(path, profilesJSONFile) {
|
||||
t.Fatalf("metadata path = %q", path)
|
||||
}
|
||||
return []byte(metadata), nil
|
||||
})
|
||||
|
||||
current, err := ResolveProfileMetadataReadOnly("/config", "")
|
||||
if err != nil || current == nil || current.UserID != "user-a" || current.UserName != "Alice" || current.CorpID != "corp-a" {
|
||||
t.Fatalf("current metadata profile = %#v, %v", current, err)
|
||||
}
|
||||
explicit, err := ResolveProfileMetadataReadOnly("/config", "beta")
|
||||
if err != nil || explicit == nil || explicit.UserID != "user-b" || explicit.CorpID != "corp-b" {
|
||||
t.Fatalf("explicit metadata profile = %#v, %v", explicit, err)
|
||||
}
|
||||
if reads != 2 {
|
||||
t.Fatalf("profile metadata reads = %d, want 2", reads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyFailsClosed(t *testing.T) {
|
||||
fail := errors.New("read failed")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
read func(string) ([]byte, error)
|
||||
wantErr string
|
||||
}{
|
||||
{name: "missing", read: func(string) ([]byte, error) { return nil, os.ErrNotExist }},
|
||||
{name: "read error", read: func(string) ([]byte, error) { return nil, fail }, wantErr: "read profile metadata"},
|
||||
{name: "corrupt", read: func(string) ([]byte, error) { return []byte("{"), nil }, wantErr: "parse profile metadata"},
|
||||
{name: "forward version", read: func(string) ([]byte, error) { return []byte(`{"version":999}`), nil }, wantErr: "newer than supported"},
|
||||
{name: "no current", read: func(string) ([]byte, error) { return []byte(`{"version":3,"profiles":[]}`), nil }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &profilesReadFile, tc.read)
|
||||
testseam.Swap(t, &profilesRename, func(string, string) error {
|
||||
t.Fatal("read-only metadata resolution attempted a quarantine rename")
|
||||
return nil
|
||||
})
|
||||
got, err := ResolveProfileMetadataReadOnly("/config", "")
|
||||
if tc.wantErr == "" {
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("read-only metadata = %#v, %v", got, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || got != nil {
|
||||
t.Fatalf("read-only metadata = %#v, %v; want %q", got, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyRejectsUnknownSelector(t *testing.T) {
|
||||
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) {
|
||||
return []byte(`{"version":3,"currentProfile":"corp-a:user-a","profiles":[{"name":"alpha","corpId":"corp-a","userId":"user-a"}]}`), nil
|
||||
})
|
||||
profile, err := ResolveProfileMetadataReadOnly("/config", "missing")
|
||||
if err == nil || profile != nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("unknown read-only profile = %#v, %v", profile, err)
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,15 @@ type FlagSpec struct {
|
||||
// alike) and makes a whitespace-only value count as empty in required checks.
|
||||
Trim bool
|
||||
|
||||
// Input declares extra input sources for a KindString flag beyond the
|
||||
// literal command-line value: InputFile enables @path (value replaced by
|
||||
// the file content), InputStdin enables - (value replaced by stdin).
|
||||
// "@@value" always escapes to the literal "@value". Only explicit CLI
|
||||
// tokens are resolved; EnvVar fallback and registration defaults pass
|
||||
// through unchanged. Resolution runs before required/enum/constraint/
|
||||
// Validate checks, so they see the payload content. Empty = flag value only.
|
||||
Input []string
|
||||
|
||||
// Schema parameter final facts (embedded to dws.schema.*; assembly pass-through).
|
||||
Enum []string // accepted values
|
||||
Format string // machine-readable format (e.g. uri)
|
||||
@@ -365,6 +374,7 @@ func New(spec Spec) *cobra.Command {
|
||||
validateDispatchDecl(spec)
|
||||
validateSafetySpec(spec)
|
||||
validateContractDecl(spec)
|
||||
validateInputSpecs(spec.Use, spec.Flags)
|
||||
// Help prose inherits the declaration when not authored separately:
|
||||
// Selection.Examples (already contract-validated against the real flags)
|
||||
// double as the --help Example block, keeping one authored source.
|
||||
@@ -476,6 +486,11 @@ func runDeclaredPreflight(cmd *cobra.Command, args []string, spec Spec) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Input resolution rewrites explicit @file / stdin values in place so the
|
||||
// required/enum/constraint/Validate stages below check the payload content.
|
||||
if err := resolveInputFlags(cmd, spec.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateRequired(cmd, spec.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package corecmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
// FlagSpec.Input source constants. They declare extra input sources for a
|
||||
// KindString flag beyond the literal command-line value, mirroring the
|
||||
// lark-cli Flag.Input capability so large payloads (markdown, JSON, CSV)
|
||||
// never need shell quoting.
|
||||
const (
|
||||
// InputFile allows the flag value @path to be replaced by the file content.
|
||||
InputFile = "file"
|
||||
// InputStdin allows the flag value - to be replaced by the stdin content.
|
||||
InputStdin = "stdin"
|
||||
)
|
||||
|
||||
// utf8BOM is stripped from file/stdin content so a Windows-edited payload
|
||||
// cannot corrupt the first CSV cell or break JSON parsing downstream.
|
||||
const utf8BOM = "\ufeff"
|
||||
|
||||
// validateInputSpecs rejects malformed Input declarations at build time. Like
|
||||
// the other declaration checks this panics: a bad Input spec is a programming
|
||||
// error every test and startup path should trip immediately.
|
||||
func validateInputSpecs(use string, flags []FlagSpec) {
|
||||
for _, flag := range flags {
|
||||
if len(flag.Input) == 0 {
|
||||
continue
|
||||
}
|
||||
if flag.Kind != KindString {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q flag %q: Input is only supported on KindString flags",
|
||||
use, flag.Name))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, source := range flag.Input {
|
||||
if source != InputFile && source != InputStdin {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q flag %q: unknown Input source %q (allowed: %s, %s)",
|
||||
use, flag.Name, source, InputFile, InputStdin))
|
||||
}
|
||||
if seen[source] {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q flag %q: duplicate Input source %q",
|
||||
use, flag.Name, source))
|
||||
}
|
||||
seen[source] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inputSupports reports whether the flag declared the given input source.
|
||||
func inputSupports(flag FlagSpec, source string) bool {
|
||||
for _, s := range flag.Input {
|
||||
if s == source {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// explicitInputFlagName picks the declared name that carries the user's
|
||||
// explicit token, mirroring rawValue's order and usability judgement exactly:
|
||||
// the main flag wins only when changed and usable (trim-judged only when
|
||||
// Trim is set), then declared aliases in order. Diverging here could rewrite
|
||||
// an alias that rawValue would shadow (or vice versa). EnvVar fallback and
|
||||
// registration defaults are never input-resolved — @file only applies to what
|
||||
// the user literally typed.
|
||||
func explicitInputFlagName(cmd *cobra.Command, flag FlagSpec) string {
|
||||
usable := func(v string) bool {
|
||||
if flag.Trim {
|
||||
v = strings.TrimSpace(v)
|
||||
}
|
||||
return v != ""
|
||||
}
|
||||
for _, name := range append([]string{flag.Name}, flag.Aliases...) {
|
||||
if cmd.Flags().Changed(name) && usable(cmdutil.MustGetFlag(cmd, name)) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveInputFlags rewrites the explicit values of Input-declaring flags
|
||||
// before required/enum/constraint/Validate checks, so every downstream stage
|
||||
// sees the real payload content. Semantics:
|
||||
//
|
||||
// - "-" reads stdin; a process has a single stdin, so a second Input flag
|
||||
// using "-" in the same invocation is rejected.
|
||||
// - "@path" reads the named file (leading/trailing whitespace trimmed).
|
||||
// - "@@value" escapes to the literal "@value" and is not source-resolved.
|
||||
//
|
||||
// Resolution runs once per invocation inside runDeclaredPreflight. It
|
||||
// rewrites the cobra flag value in place (Set keeps Changed=true), so
|
||||
// fallback-chain readers (EffectiveValue/BuildArgs) observe the content.
|
||||
//
|
||||
// Interaction with confirmation: stdin is consumed here, before the Safety
|
||||
// prompt of a user_required write would read it; such an interactive prompt
|
||||
// then sees EOF and fails closed with confirmation_required. Write commands
|
||||
// declaring InputStdin must be invoked with --yes (or --dry-run).
|
||||
func resolveInputFlags(cmd *cobra.Command, flags []FlagSpec) error {
|
||||
stdinConsumed := false
|
||||
for _, flag := range flags {
|
||||
if len(flag.Input) == 0 {
|
||||
continue
|
||||
}
|
||||
name := explicitInputFlagName(cmd, flag)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
raw := cmdutil.MustGetFlag(cmd, name)
|
||||
// Trim flags judge usability on the trimmed value (rawValue), so the
|
||||
// prefix check must agree or " @path" would slip through unresolved.
|
||||
if flag.Trim {
|
||||
raw = strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
switch {
|
||||
case raw == "-":
|
||||
if !inputSupports(flag, InputStdin) {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s 不支持 stdin 输入(-)", flag.Name))
|
||||
}
|
||||
if stdinConsumed {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s:stdin(-)只能被一个参数使用", flag.Name),
|
||||
apperrors.WithHint(fmt.Sprintf(
|
||||
"一个进程只有一份 stdin,其余参数请内联传值或用 @文件路径(如 --%s @./payload.json)",
|
||||
flag.Name)))
|
||||
}
|
||||
stdinConsumed = true
|
||||
data, err := io.ReadAll(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s 读取 stdin 失败:%v", flag.Name, err))
|
||||
}
|
||||
// Setting a registered string flag cannot fail.
|
||||
_ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM))
|
||||
|
||||
case strings.HasPrefix(raw, "@@"):
|
||||
// Escape: strip the first @, keep the rest as a literal inline value.
|
||||
_ = cmd.Flags().Set(name, raw[1:])
|
||||
|
||||
case strings.HasPrefix(raw, "@"):
|
||||
if !inputSupports(flag, InputFile) {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s 不支持文件输入(@路径)", flag.Name))
|
||||
}
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s:@ 后的文件路径不能为空", flag.Name))
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
var opts []apperrors.Option
|
||||
if inputSupports(flag, InputStdin) {
|
||||
// Rejected @file paths are usually absolute (temp files).
|
||||
// Steer toward stdin rather than copying the file around.
|
||||
opts = append(opts, apperrors.WithHint(fmt.Sprintf(
|
||||
"该参数也支持 stdin:把文件内容管道进命令并传 --%s -", flag.Name)))
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s 读取文件 %q 失败:%v", flag.Name, path, err), opts...)
|
||||
}
|
||||
_ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package corecmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newInputCommand builds a Spec whose Invoke captures toolArgs, so tests can
|
||||
// assert what the full pipeline (resolution → validation → BuildArgs) ships.
|
||||
func newInputCommand(flags []FlagSpec, captured *map[string]any) *cobra.Command {
|
||||
return New(Spec{
|
||||
Use: "t",
|
||||
Short: "t",
|
||||
Flags: flags,
|
||||
Invoke: func(c *Ctx, toolArgs map[string]any) error {
|
||||
*captured = toolArgs
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runInputCommand(t *testing.T, cmd *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func writeInputFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "payload.txt")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsFile(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "# hello\nworld")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "# hello\nworld" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsStdin(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
|
||||
}, &got)
|
||||
cmd.SetIn(strings.NewReader("piped payload"))
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "-"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "piped payload" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// The @@ escape keeps a literal leading @ inline and must not be treated as a
|
||||
// source reference even when Input is declared.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsEscapedAtStaysInline(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
|
||||
}, &got)
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "@@literal@x"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "@literal@x" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsBOMStripped(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "\ufeff{\"a\":1}")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "{\"a\":1}" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// Required must be satisfied by the resolved payload, proving resolution runs
|
||||
// before the required stage.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsSatisfiesRequired(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Required: true, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "payload")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "payload" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// Enum validation sees the resolved content, not the "@path" token.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsEnumValidatesResolvedContent(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "mode", Usage: "M", Bind: "mode", Enum: []string{"asc", "desc"}, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "sideways")
|
||||
|
||||
err := runInputCommand(t, cmd, "--mode", "@"+path)
|
||||
if err == nil || !strings.Contains(err.Error(), "不合法") {
|
||||
t.Fatalf("expected enum rejection on resolved content, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A value passed through a declared alias is resolved exactly like the main
|
||||
// name (fallback-chain parity).
|
||||
func TestCrossPlatformCoverageResolveInputFlagsAliasResolved(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "via alias")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--body", "@"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "via alias" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// When the main flag shadows a changed alias (rawValue usability order), the
|
||||
// shadowed alias must not be input-resolved: resolution targets exactly the
|
||||
// name the fallback chain will read. The whitespace main value is usable for
|
||||
// a non-Trim flag, and the alias path does not exist on purpose — a resolver
|
||||
// that wrongly picked the alias would fail the read instead of shipping " ".
|
||||
func TestCrossPlatformCoverageResolveInputFlagsShadowedAliasNotResolved(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
missing := filepath.Join(t.TempDir(), "missing.txt")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", " ", "--body", "@"+missing); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != " " {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsFileNotSupported(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
|
||||
}, &got)
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "@/tmp/whatever.txt")
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持文件输入") {
|
||||
t.Fatalf("expected file-input rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsStdinNotSupported(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
|
||||
}, &got)
|
||||
cmd.SetIn(strings.NewReader("x"))
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "-")
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持 stdin") {
|
||||
t.Fatalf("expected stdin rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsSingleStdinConsumer(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "first", Usage: "F", Bind: "first", Input: []string{InputStdin}},
|
||||
{Name: "second", Usage: "S", Bind: "second", Input: []string{InputStdin}},
|
||||
}, &got)
|
||||
cmd.SetIn(strings.NewReader("x"))
|
||||
|
||||
err := runInputCommand(t, cmd, "--first", "-", "--second", "-")
|
||||
if err == nil || !strings.Contains(err.Error(), "只能被一个参数使用") {
|
||||
t.Fatalf("expected single-stdin rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// failingReader (corecmd_test.go) fails every read, making the stdin error
|
||||
// branch reachable.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsStdinReadFailure(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
|
||||
}, &got)
|
||||
cmd.SetIn(failingReader{})
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "-")
|
||||
if err == nil || !strings.Contains(err.Error(), "读取 stdin 失败") {
|
||||
t.Fatalf("expected stdin read failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsFileNotFound(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
|
||||
}, &got)
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "@"+filepath.Join(t.TempDir(), "missing.txt"))
|
||||
if err == nil || !strings.Contains(err.Error(), "读取文件") {
|
||||
t.Fatalf("expected read failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsEmptyPathAfterAt(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
|
||||
}, &got)
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "@ ")
|
||||
if err == nil || !strings.Contains(err.Error(), "文件路径不能为空") {
|
||||
t.Fatalf("expected empty-path rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A flag without Input keeps its literal value even when it looks like a
|
||||
// source reference — resolution is strictly opt-in per declaration.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsNoInputSpecPassthrough(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "token", Usage: "T", Bind: "token"},
|
||||
}, &got)
|
||||
|
||||
if err := runInputCommand(t, cmd, "--token", "@not-a-file"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["token"] != "@not-a-file" {
|
||||
t.Fatalf("toolArgs[token] = %q", got["token"])
|
||||
}
|
||||
}
|
||||
|
||||
// Registration defaults and env fallback are never input-resolved: @file only
|
||||
// applies to what the user literally typed on the command line.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsDefaultNotResolved(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Default: "@not-a-file", Input: []string{InputFile}},
|
||||
}, &got)
|
||||
|
||||
if err := runInputCommand(t, cmd); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "@not-a-file" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// Trim flags judge usability on the trimmed value (rawValue), so a leading
|
||||
// whitespace before @ must still resolve instead of shipping as a literal.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsTrimmedLeadingSpace(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Trim: true, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "trimmed payload")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", " @"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "trimmed payload" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateInputSpecsPanics(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flag FlagSpec
|
||||
}{
|
||||
{"non-string kind", FlagSpec{Name: "n", Kind: KindInt, Input: []string{InputFile}}},
|
||||
{"unknown source", FlagSpec{Name: "s", Input: []string{"url"}}},
|
||||
{"duplicate source", FlagSpec{Name: "s", Input: []string{InputFile, InputFile}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic")
|
||||
}
|
||||
}()
|
||||
New(Spec{
|
||||
Use: "t",
|
||||
Short: "t",
|
||||
Flags: []FlagSpec{tc.flag},
|
||||
Invoke: func(c *Ctx, toolArgs map[string]any) error { return nil },
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -428,7 +428,7 @@ func newWikiCommand() *cobra.Command {
|
||||
})
|
||||
|
||||
// space create flags
|
||||
spaceCreateCmd.Flags().String("name", "", "知识库名称 (必填,不超过 100 字符)")
|
||||
spaceCreateCmd.Flags().String("name", "", "知识库名称 (必填,不超过 32 字符)")
|
||||
spaceCreateCmd.Flags().String("desc", "", "知识库描述 (选填,不超过 500 字符)")
|
||||
spaceCreateCmd.Flags().String("icon", "", "知识库图标标识 (选填)")
|
||||
|
||||
@@ -696,7 +696,7 @@ func newWikiCommand() *cobra.Command {
|
||||
Short: "查询知识库成员列表",
|
||||
Long: `查询指定知识库的成员列表,返回每位成员的 userId、姓名、角色等信息。
|
||||
|
||||
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。
|
||||
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 50)。
|
||||
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围;
|
||||
ORG 类型授权不会出现在查询结果中。`,
|
||||
Example: ` dws wiki member list --workspace <workspaceId>
|
||||
@@ -717,6 +717,9 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
}
|
||||
if limit > 0 {
|
||||
if limit > 50 {
|
||||
return fmt.Errorf("--limit 不能超过 50;底层成员接口不提供游标续页")
|
||||
}
|
||||
toolArgs["maxResults"] = limit
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
@@ -762,7 +765,7 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
})
|
||||
|
||||
memberListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50;底层不支持游标续页")
|
||||
memberListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = memberListCmd.Flags().MarkHidden("max-results")
|
||||
memberListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
|
||||
@@ -217,6 +217,7 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
|
||||
RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc),
|
||||
Enum: append([]string(nil), f.Enum...),
|
||||
Aliases: append([]string(nil), f.Aliases...),
|
||||
Input: append([]string(nil), f.Input...),
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageWikiSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_wiki.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "wiki" {
|
||||
t.Fatalf("service = %q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "wiki" {
|
||||
if _, exists := registered[item.Command]; exists {
|
||||
t.Fatalf("duplicate %s", item.Command)
|
||||
}
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if len(registered) != 20 || len(source.Shortcuts) != 20 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 20/20", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
var missing, stale []string
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || item.Hidden || !record.Public {
|
||||
t.Errorf("%s: not publicly reviewed", command)
|
||||
}
|
||||
if strings.TrimSpace(record.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta || item.Disposition != record.Disposition {
|
||||
t.Errorf("%s: semantic facts drifted", command)
|
||||
}
|
||||
if item.Risk != record.Risk {
|
||||
t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s: incomplete contract/safety/result/output", command)
|
||||
}
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
}
|
||||
@@ -404,6 +404,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"todo\u0000+overdue": {},
|
||||
"todo\u0000+remind": {},
|
||||
"todo\u0000+todo-done": {},
|
||||
"wiki\u0000+delete-space": {},
|
||||
"wiki\u0000+feed-list": {},
|
||||
"wiki\u0000+member-add": {},
|
||||
"wiki\u0000+member-list": {},
|
||||
"wiki\u0000+member-remove": {},
|
||||
"wiki\u0000+member-update": {},
|
||||
"wiki\u0000+move": {},
|
||||
"wiki\u0000+move-to-drive": {},
|
||||
"wiki\u0000+node-copy": {},
|
||||
"wiki\u0000+node-create": {},
|
||||
"wiki\u0000+node-delete": {},
|
||||
"wiki\u0000+node-get": {},
|
||||
"wiki\u0000+node-list": {},
|
||||
"wiki\u0000+node-search": {},
|
||||
"wiki\u0000+resolve-space": {},
|
||||
"wiki\u0000+space-create": {},
|
||||
"wiki\u0000+space-get": {},
|
||||
"wiki\u0000+space-list": {},
|
||||
"wiki\u0000+space-search": {},
|
||||
"wiki\u0000+wiki-new-doc": {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ var minutesSemanticCatalogJSON []byte
|
||||
//go:embed semantic_catalog_drive.json
|
||||
var driveSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_wiki.json
|
||||
var wikiSemanticCatalogJSON []byte
|
||||
|
||||
type semanticCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Service string `json:"service"`
|
||||
@@ -48,6 +51,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
aitableSemanticCatalogJSON,
|
||||
minutesSemanticCatalogJSON,
|
||||
driveSemanticCatalogJSON,
|
||||
wikiSemanticCatalogJSON,
|
||||
)
|
||||
|
||||
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "wiki",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+space-list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。","risk":"read","public":true,"reviewed":true},
|
||||
"+space-search": {"disposition":"semantic_adapter","semantic_delta":"按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+resolve-space": {"disposition":"primary_smart","semantic_delta":"把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。","risk":"read","public":true,"reviewed":true},
|
||||
"+space-get": {"disposition":"schema_leaf","semantic_delta":"补充知识库详情入口,并要求 workspaceId 业务证据。","risk":"read","public":true,"reviewed":true},
|
||||
"+space-create": {"disposition":"primary_smart","semantic_delta":"创建后要求 workspaceId 并通过空间详情读回验证真实落库。","risk":"write","public":true,"reviewed":true},
|
||||
"+delete-space": {"disposition":"semantic_adapter","semantic_delta":"删除前读取影响目标,经高风险确认后只接受 success=true 终态。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
|
||||
"+member-add": {"disposition":"semantic_adapter","semantic_delta":"支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true},
|
||||
"+member-update": {"disposition":"semantic_adapter","semantic_delta":"补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true},
|
||||
"+member-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。","risk":"read","public":true,"reviewed":true},
|
||||
"+member-remove": {"disposition":"semantic_adapter","semantic_delta":"支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true},
|
||||
|
||||
"+node-list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。","risk":"read","public":true,"reviewed":true},
|
||||
"+node-get": {"disposition":"schema_leaf","semantic_delta":"统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。","risk":"read","public":true,"reviewed":true},
|
||||
"+node-search": {"disposition":"semantic_adapter","semantic_delta":"补充库内关键词和扩展名搜索,并拒绝假空结果。","risk":"read","public":true,"reviewed":true},
|
||||
"+node-create": {"disposition":"primary_smart","semantic_delta":"支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。","risk":"write","public":true,"reviewed":true},
|
||||
"+node-copy": {"disposition":"primary_smart","semantic_delta":"高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+move": {"disposition":"primary_smart","semantic_delta":"同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。","risk":"write","public":true,"reviewed":true},
|
||||
"+move-to-drive": {"disposition":"primary_smart","semantic_delta":"同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。","risk":"write","public":true,"reviewed":true},
|
||||
"+node-delete": {"disposition":"semantic_adapter","semantic_delta":"删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
|
||||
"+feed-list": {"disposition":"semantic_adapter","semantic_delta":"补充知识库协作动态查询,严格验证 feeds 并保留游标。","risk":"read","public":true,"reviewed":true},
|
||||
"+wiki-new-doc": {"disposition":"primary_smart","semantic_delta":"按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。","risk":"write","public":true,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -103,6 +104,8 @@ func runShortcut(t *testing.T, fake *stubMailboxCaller, argv ...string) string {
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
var buf bytes.Buffer
|
||||
root.SetOut(&buf)
|
||||
@@ -121,6 +124,8 @@ func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
@@ -35,15 +41,25 @@ import (
|
||||
//
|
||||
// dws wiki +resolve-space --name 产品文档
|
||||
var ResolveSpace = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+resolve-space",
|
||||
Product: "wiki",
|
||||
Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "wiki",
|
||||
Command: "+resolve-space",
|
||||
Product: "wiki",
|
||||
Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)",
|
||||
Intent: "当你只知道某个知识空间(wiki space)的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 spaceId 时使用;" +
|
||||
"内部按 --name 关键词调用 search_wikiSpaces 搜索知识空间,再在本地投影出每个候选的 spaceId 和 name。" +
|
||||
"如果只命中一个知识空间就直接返回它的 spaceId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。" +
|
||||
"这是纯只读操作,只做搜索与本地投影,不会修改任何知识空间。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"知识库名称解析结果","properties":{"resolved":{"type":"boolean","description":"是否唯一解析"},"spaceId":{"type":"string","description":"唯一知识库 ID"},"name":{"type":"string","description":"唯一知识库名称"},"count":{"type":"integer","description":"候选数量"},"candidates":{"type":"array","description":"需要消歧的候选知识库","items":{"type":"object","description":"知识库候选","additionalProperties":true}}},"required":["resolved"],"additionalProperties":true}`)},
|
||||
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Reviewed Wiki resolver: the executable CLI strictly validates search results and refuses to guess when multiple spaces match."},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "按名称搜索知识空间并解析出唯一 spaceId(只读)", UseWhen: []string{"当你只知道某个知识空间(wiki space)的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 spaceId 时使用;内部按 --name 关键词调用 search_wikiSpaces 搜索知识空间,再在本地投影出每个候选的 spaceId 和 name。如果只命中一个知识空间就直接返回它的 spaceId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。这是纯只读操作,只做搜索与本地投影,不会修改任何知识空间。"}, AvoidWhen: []string{"只想浏览所有匹配项用 wiki +space-search;已知 workspaceId 时无需解析"}, Examples: []string{`dws wiki +resolve-space --name "产品文档"`}},
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: "shortcut_resolve_space", CanonicalPath: "wiki.shortcut_resolve_space", CLIPath: "wiki +resolve-space", PrimaryCLIPath: "wiki +resolve-space"},
|
||||
Parameters: []contract.ParamDecl{{Name: "name", Property: "keyword"}},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "要搜索的知识空间名称关键词(必填)", Required: true},
|
||||
},
|
||||
@@ -62,7 +78,10 @@ var ResolveSpace = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
// Project candidates to {spaceId, name}, defensively unwrapping the list.
|
||||
items := resolveSpaceItems(data)
|
||||
items, err := resolveSpaceItemsStrict(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidates := make([]map[string]any, 0, len(items))
|
||||
for _, s := range items {
|
||||
candidates = append(candidates, map[string]any{
|
||||
@@ -123,7 +142,7 @@ func resolveSpaceItems(data map[string]any) []map[string]any {
|
||||
|
||||
// resolveSpaceID reads a space's identifier, tolerating the common id keys.
|
||||
func resolveSpaceID(s map[string]any) string {
|
||||
for _, key := range []string{"spaceId", "space_id", "id"} {
|
||||
for _, key := range []string{"workspaceId", "spaceId", "space_id", "id"} {
|
||||
if v, ok := s[key].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
@@ -131,6 +150,53 @@ func resolveSpaceID(s map[string]any) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveSpaceItemsStrict(data map[string]any) ([]map[string]any, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 返回空响应,不能当作零命中", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("empty_tool_response"))
|
||||
}
|
||||
if success, present := data["success"]; present {
|
||||
value, ok := success.(bool)
|
||||
if !ok || !value {
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 未成功", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("remote_failure"))
|
||||
}
|
||||
}
|
||||
containers := []map[string]any{data}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if raw, present := data[wrapper]; present {
|
||||
inner, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 响应包装不是对象", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_envelope"))
|
||||
}
|
||||
containers = append(containers, inner)
|
||||
}
|
||||
}
|
||||
for _, container := range containers {
|
||||
for _, key := range []string{"wikiSpaces", "spaces", "items", "list", "records"} {
|
||||
raw, present := container[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 业务集合不是数组", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection"))
|
||||
}
|
||||
out := make([]map[string]any, 0, len(list))
|
||||
for index, item := range list {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 第 %d 项不是对象", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item"))
|
||||
}
|
||||
if resolveSpaceID(object) == "" || resolveSpaceName(object) == "" {
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 第 %d 项缺少名称或 workspaceId", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item"))
|
||||
}
|
||||
out = append(out, object)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 缺少 wikiSpaces 数组,不能投影为空", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("missing_collection"))
|
||||
}
|
||||
|
||||
// resolveSpaceName reads a space's display name, tolerating the common name keys.
|
||||
func resolveSpaceName(s map[string]any) string {
|
||||
for _, key := range []string{"name", "spaceName", "title"} {
|
||||
|
||||
@@ -14,10 +14,14 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
@@ -33,15 +37,25 @@ import (
|
||||
//
|
||||
// dws wiki +wiki-new-doc --space "产品文档库" --title "需求评审纪要"
|
||||
var WikiNewDoc = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+wiki-new-doc",
|
||||
Product: "wiki",
|
||||
Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "wiki",
|
||||
Command: "+wiki-new-doc",
|
||||
Product: "wiki",
|
||||
Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)",
|
||||
Intent: "当你只知道知识库(知识空间)的名字、想直接在它下面新建一篇文档,却不想先搜索空间、复制 workspaceId 再建节点时使用;" +
|
||||
"内部先按空间名搜索知识库,若唯一命中则拿到它的 workspaceId,再在该库根目录下创建一个在线文档节点。" +
|
||||
"如果这个名字没有匹配到任何知识库,或匹配到多个,会报错让你用更精确的名字,绝不乱猜。" +
|
||||
"这会真实创建一个新的文档节点。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "non_idempotent"},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的新建 Wiki 文档","properties":{"success":{"type":"boolean","description":"是否成功"},"nodeId":{"type":"string","description":"新文档节点 ID"},"space":{"type":"string","description":"请求的知识库名称"},"title":{"type":"string","description":"请求的文档标题"},"document":{"type":"object","description":"读回的文档元数据","additionalProperties":true}},"required":["success","nodeId","space","title","document"],"additionalProperties":true}`)},
|
||||
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Reviewed Wiki smart Shortcut: the executable CLI strictly resolves one exact space, creates a document, and verifies it through a metadata read-back."},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)", UseWhen: []string{"当你只知道知识库(知识空间)的名字、想直接在它下面新建一篇文档,却不想先搜索空间、复制 workspaceId 再建节点时使用;内部先按空间名搜索知识库,若唯一命中则拿到它的 workspaceId,再在该库根目录下创建一个在线文档节点。如果这个名字没有匹配到任何知识库,或匹配到多个,会报错让你用更精确的名字,绝不乱猜。这会真实创建一个新的文档节点。"}, AvoidWhen: []string{"已知 workspaceId 时用 wiki +node-create;空间名不唯一时先用 wiki +space-search"}, Examples: []string{`dws wiki +wiki-new-doc --space "产品文档库" --title "需求评审纪要"`}},
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: "shortcut_wiki_new_doc", CanonicalPath: "wiki.shortcut_wiki_new_doc", CLIPath: "wiki +wiki-new-doc", PrimaryCLIPath: "wiki +wiki-new-doc"},
|
||||
Parameters: []contract.ParamDecl{{Name: "space", Property: "keyword"}, {Name: "title", Property: "name"}},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "space", Type: shortcut.FlagString, Desc: "知识库(知识空间)名称", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "新建文档的标题", Required: true},
|
||||
@@ -88,7 +102,7 @@ var WikiNewDoc = shortcut.Shortcut{
|
||||
"wouldCreateIn": workspaceID,
|
||||
})
|
||||
}
|
||||
created, err := rt.CallMCPWriteData("doc", "create_file", map[string]any{
|
||||
created, err := rt.CallMCPWriteDataStrict("doc", "create_file", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"name": title,
|
||||
"type": "adoc",
|
||||
@@ -96,13 +110,28 @@ var WikiNewDoc = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Surface the created doc (id/url in the response) instead of returning
|
||||
// silently — previously the caller got no confirmation of the new doc.
|
||||
if success, ok := created["success"].(bool); !ok || !success {
|
||||
return apperrors.NewAPI("create_file 未返回 success=true,无法证明文档已创建", apperrors.WithOperation("doc/create_file"), apperrors.WithReason("missing_terminal_success"))
|
||||
}
|
||||
nodeID := wikiNewDocFirstString(created, "nodeId", "fileId", "id")
|
||||
if nodeID == "" {
|
||||
return apperrors.NewAPI("create_file 未返回 nodeId,远端效果未知", apperrors.WithOperation("doc/create_file"), apperrors.WithReason("missing_created_id"))
|
||||
}
|
||||
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": nodeID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if success, present := verified["success"]; present {
|
||||
value, ok := success.(bool)
|
||||
if !ok || !value {
|
||||
return apperrors.NewAPI("新建文档读回未成功", apperrors.WithOperation("doc/get_document_info"), apperrors.WithReason("readback_failed"))
|
||||
}
|
||||
}
|
||||
if wikiNewDocFirstString(verified, "nodeId", "fileId", "id") != nodeID {
|
||||
return apperrors.NewAPI("新建文档读回 nodeId 不一致", apperrors.WithOperation("doc/get_document_info"), apperrors.WithReason("readback_id_mismatch"))
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"created": true,
|
||||
"space": spaceName,
|
||||
"title": title,
|
||||
"result": created,
|
||||
"success": true, "nodeId": nodeID, "space": spaceName, "title": title, "document": verified,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -117,7 +146,10 @@ type wikiSpaceCandidate struct {
|
||||
// out of a search_wikiSpaces response and returns its workspaceId. It errors
|
||||
// clearly when nothing matches or when the name is ambiguous, never guessing.
|
||||
func wikiNewDocResolveSpaceID(data map[string]any, spaceName string) (string, error) {
|
||||
spaces := wikiNewDocExtractSpaces(data)
|
||||
spaces, err := wikiNewDocExtractSpaces(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(spaces) == 0 {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf(
|
||||
"没找到名为 %q 的知识库;换个更完整/精确的空间名再试。", spaceName))
|
||||
@@ -156,9 +188,15 @@ func wikiNewDocResolveSpaceID(data map[string]any, spaceName string) (string, er
|
||||
// wikiNewDocExtractSpaces flattens the several shapes a search_wikiSpaces
|
||||
// response may take into a list of {id, name} candidates. The gateway wraps the
|
||||
// list under one of several common container keys, so probe them defensively.
|
||||
func wikiNewDocExtractSpaces(data map[string]any) []wikiSpaceCandidate {
|
||||
func wikiNewDocExtractSpaces(data map[string]any) ([]wikiSpaceCandidate, error) {
|
||||
if data == nil {
|
||||
return nil
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 返回空响应,不能当作零命中", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("empty_tool_response"))
|
||||
}
|
||||
if success, present := data["success"]; present {
|
||||
value, ok := success.(bool)
|
||||
if !ok || !value {
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 未成功", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("remote_failure"))
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"result", "data", "list", "wikiSpaces", "spaces", "items", "records"} {
|
||||
switch v := data[key].(type) {
|
||||
@@ -166,21 +204,25 @@ func wikiNewDocExtractSpaces(data map[string]any) []wikiSpaceCandidate {
|
||||
return wikiNewDocToCandidates(v)
|
||||
case map[string]any:
|
||||
for _, k2 := range []string{"list", "wikiSpaces", "spaces", "items", "records", "result"} {
|
||||
if arr, ok := v[k2].([]any); ok {
|
||||
if raw, present := v[k2]; present {
|
||||
arr, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 业务集合不是数组", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection"))
|
||||
}
|
||||
return wikiNewDocToCandidates(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 缺少 wikiSpaces 数组,不能投影为空", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("missing_collection"))
|
||||
}
|
||||
|
||||
func wikiNewDocToCandidates(arr []any) []wikiSpaceCandidate {
|
||||
func wikiNewDocToCandidates(arr []any) ([]wikiSpaceCandidate, error) {
|
||||
out := make([]wikiSpaceCandidate, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
for index, it := range arr {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 结果第 %d 项不是对象", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item"))
|
||||
}
|
||||
id := ""
|
||||
for _, k := range []string{"workspaceId", "spaceId", "id"} {
|
||||
@@ -196,12 +238,30 @@ func wikiNewDocToCandidates(arr []any) []wikiSpaceCandidate {
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" && name == "" {
|
||||
continue
|
||||
if id == "" || name == "" {
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 结果第 %d 项缺少名称或 workspaceId", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item"))
|
||||
}
|
||||
out = append(out, wikiSpaceCandidate{id: id, name: name})
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func wikiNewDocFirstString(data map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if inner, ok := data[wrapper].(map[string]any); ok {
|
||||
for _, key := range keys {
|
||||
if value, ok := inner[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func wikiNewDocLabels(spaces []wikiSpaceCandidate) []string {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveSpaceStrictContracts(t *testing.T) {
|
||||
invalid := []map[string]any{
|
||||
nil,
|
||||
{"success": false},
|
||||
{"success": "yes"},
|
||||
{"result": "bad"},
|
||||
{"wikiSpaces": "bad"},
|
||||
{"wikiSpaces": []any{"bad"}},
|
||||
{"wikiSpaces": []any{map[string]any{"workspaceId": "w"}}},
|
||||
{"wikiSpaces": []any{map[string]any{"name": "Docs"}}},
|
||||
{"success": true},
|
||||
}
|
||||
for index, data := range invalid {
|
||||
if _, err := resolveSpaceItemsStrict(data); err == nil {
|
||||
t.Fatalf("invalid response %d succeeded: %#v", index, data)
|
||||
}
|
||||
}
|
||||
|
||||
for _, data := range []map[string]any{
|
||||
{"wikiSpaces": []any{}},
|
||||
{"spaces": []any{map[string]any{"spaceId": "s", "spaceName": "Docs"}}},
|
||||
{"result": map[string]any{"items": []any{map[string]any{"id": "i", "title": "Plan"}}}},
|
||||
{"data": map[string]any{"records": []any{map[string]any{"workspaceId": "w", "name": "Roadmap"}}}},
|
||||
} {
|
||||
if _, err := resolveSpaceItemsStrict(data); err != nil {
|
||||
t.Fatalf("valid response rejected: %#v: %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
data map[string]any
|
||||
id string
|
||||
name string
|
||||
}{
|
||||
{map[string]any{"workspaceId": "w", "name": "N"}, "w", "N"},
|
||||
{map[string]any{"spaceId": "s", "spaceName": "S"}, "s", "S"},
|
||||
{map[string]any{"space_id": "legacy", "title": "T"}, "legacy", "T"},
|
||||
{map[string]any{"id": "i"}, "i", ""},
|
||||
{map[string]any{}, "", ""},
|
||||
} {
|
||||
if got := resolveSpaceID(tc.data); got != tc.id {
|
||||
t.Fatalf("resolveSpaceID(%#v)=%q want %q", tc.data, got, tc.id)
|
||||
}
|
||||
if got := resolveSpaceName(tc.data); got != tc.name {
|
||||
t.Fatalf("resolveSpaceName(%#v)=%q want %q", tc.data, got, tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveSpaceExecution(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fake *stubMailboxCaller
|
||||
ok bool
|
||||
}{
|
||||
{"transport error", &stubMailboxCaller{errTool: "search_wikiSpaces"}, false},
|
||||
{"malformed result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"success":true}`}}, false},
|
||||
{"zero result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[]}`}}, false},
|
||||
{"one result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[{"workspaceId":"w","name":"Docs"}]}`}}, true},
|
||||
{"multiple results", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[{"workspaceId":"w1","name":"Docs"},{"workspaceId":"w2","name":"Docs 2"}]}`}}, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := runShortcutErr(t, tc.fake, "wiki", "+resolve-space", "--name", "Docs", "--format", "json")
|
||||
if (err == nil) != tc.ok {
|
||||
t.Fatalf("success=%v want %v; err=%v", err == nil, tc.ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiNewDocParsing(t *testing.T) {
|
||||
invalid := []map[string]any{
|
||||
nil,
|
||||
{"success": false},
|
||||
{"success": "yes"},
|
||||
{"result": map[string]any{"wikiSpaces": "bad"}},
|
||||
{"wikiSpaces": []any{"bad"}},
|
||||
{"wikiSpaces": []any{map[string]any{"workspaceId": "w"}}},
|
||||
{"success": true},
|
||||
}
|
||||
for index, data := range invalid {
|
||||
if _, err := wikiNewDocExtractSpaces(data); err == nil {
|
||||
t.Fatalf("invalid response %d succeeded: %#v", index, data)
|
||||
}
|
||||
}
|
||||
|
||||
for _, data := range []map[string]any{
|
||||
{"wikiSpaces": []any{}},
|
||||
{"list": []any{map[string]any{"workspaceId": "w", "name": "Docs"}}},
|
||||
{"data": map[string]any{"spaces": []any{map[string]any{"spaceId": "s", "spaceName": "Plan"}}}},
|
||||
{"result": map[string]any{"result": []any{map[string]any{"id": "i", "title": "Roadmap"}}}},
|
||||
} {
|
||||
if _, err := wikiNewDocExtractSpaces(data); err != nil {
|
||||
t.Fatalf("valid response rejected: %#v: %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := wikiNewDocFirstString(map[string]any{"nodeId": " n "}, "nodeId"); got != "n" {
|
||||
t.Fatalf("direct node id=%q", got)
|
||||
}
|
||||
if got := wikiNewDocFirstString(map[string]any{"result": map[string]any{"fileId": " f "}}, "nodeId", "fileId"); got != "f" {
|
||||
t.Fatalf("nested node id=%q", got)
|
||||
}
|
||||
if got := wikiNewDocFirstString(map[string]any{"data": map[string]any{"id": " i "}}, "id"); got != "i" {
|
||||
t.Fatalf("data node id=%q", got)
|
||||
}
|
||||
if got := wikiNewDocFirstString(map[string]any{"id": 1}, "id"); got != "" {
|
||||
t.Fatalf("invalid node id=%q", got)
|
||||
}
|
||||
labels := wikiNewDocLabels([]wikiSpaceCandidate{{id: "w1", name: "Docs"}, {id: "w2", name: "Plan"}})
|
||||
if strings.Join(labels, ",") != "Docs(w1),Plan(w2)" {
|
||||
t.Fatalf("labels=%v", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiNewDocResolution(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
spaceName string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"parser error", nil, "Docs", "", false},
|
||||
{"zero", map[string]any{"wikiSpaces": []any{}}, "Docs", "", false},
|
||||
{"unique exact", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": " docs "}, map[string]any{"workspaceId": "w2", "name": "Plan"}}}, "Docs", "w1", true},
|
||||
{"unique fallback", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": "Docs Team"}}}, "Docs", "w1", true},
|
||||
{"multiple", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": "Docs 1"}, map[string]any{"workspaceId": "w2", "name": "Docs 2"}}}, "Docs", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := wikiNewDocResolveSpaceID(tc.data, tc.spaceName)
|
||||
if (err == nil) != tc.ok || got != tc.want {
|
||||
t.Fatalf("got=%q err=%v want=%q ok=%v", got, err, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiNewDocExecution(t *testing.T) {
|
||||
if err := runShortcutErr(t, &stubMailboxCaller{}, "wiki", "+wiki-new-doc", "--space", " ", "--title", "Doc"); err == nil {
|
||||
t.Fatal("blank space succeeded")
|
||||
}
|
||||
if err := runShortcutErr(t, &stubMailboxCaller{}, "wiki", "+wiki-new-doc", "--space", "Docs", "--title", " "); err == nil {
|
||||
t.Fatal("blank title succeeded")
|
||||
}
|
||||
|
||||
validSearch := `{"wikiSpaces":[{"workspaceId":"w","name":"Docs"}]}`
|
||||
cases := []struct {
|
||||
name string
|
||||
fake *stubMailboxCaller
|
||||
args []string
|
||||
ok bool
|
||||
}{
|
||||
{"search error", &stubMailboxCaller{errTool: "search_wikiSpaces"}, nil, false},
|
||||
{"resolve error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[]}`}}, nil, false},
|
||||
{"dry run", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch}}, []string{"--dry-run"}, true},
|
||||
{"create error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch}, errTool: "create_file"}, nil, false},
|
||||
{"missing terminal success", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"result":{"fileId":"n"}}`}}, nil, false},
|
||||
{"missing created id", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true}`}}, nil, false},
|
||||
{"readback error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`}, errTool: "get_document_info"}, nil, false},
|
||||
{"readback failed", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":false}`}}, nil, false},
|
||||
{"readback malformed success", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":"yes"}`}}, nil, false},
|
||||
{"readback id mismatch", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":true,"nodeId":"other"}`}}, nil, false},
|
||||
{"verified", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"data":{"fileId":"n"}}`, "get_document_info": `{"success":true,"result":{"nodeId":"n"}}`}}, nil, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := []string{"wiki", "+wiki-new-doc", "--space", "Docs", "--title", "Doc", "--format", "json"}
|
||||
args = append(args, tc.args...)
|
||||
err := runShortcutErr(t, tc.fake, args...)
|
||||
if (err == nil) != tc.ok {
|
||||
t.Fatalf("success=%v want %v; err=%v", err == nil, tc.ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,12 @@ type Flag struct {
|
||||
// compatibility escape hatch for aliases that were historically public.
|
||||
Aliases []string `json:"-"`
|
||||
AliasesVisible bool `json:"-"`
|
||||
// Input declares extra input sources for a string flag beyond the literal
|
||||
// command-line value: "file" enables @path (value replaced by the file
|
||||
// content), "stdin" enables - (value replaced by stdin). "@@value" escapes
|
||||
// to the literal "@value". Resolution happens before Required/Enum/Validate
|
||||
// checks. Empty = flag value only.
|
||||
Input []string `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
// ConstraintKind is a machine-readable cross-parameter or custom validation
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const wikiCompositeReason = "Reviewed Wiki Shortcut composite: the executable CLI owns strict response validation, pagination projection, optional multi-step orchestration, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
|
||||
|
||||
const wikiSpaceSearchCompositeReason = "Reviewed Wiki space-search compatibility adapter: the published workflow properties query/limit remain stable while execution translates them to search_wikiSpaces.keyword/pageSize; redirecting an existing non-empty property requires a versioned Schema migration."
|
||||
|
||||
func wikiContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
cliPath := "wiki " + command
|
||||
return corecmd.ContractDecl{
|
||||
Description: description, Parameters: params, Result: result, Pagination: pagination,
|
||||
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: wikiCompositeReason},
|
||||
Selection: contract.SelectionSpec{AgentSummary: description, UseWhen: []string{useWhen}, AvoidWhen: avoidWhen, Examples: examples},
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: name, CanonicalPath: "wiki." + name, CLIPath: cliPath, PrimaryCLIPath: cliPath},
|
||||
}
|
||||
}
|
||||
|
||||
func wikiWithInterfaceReason(declared shortcut.Shortcut, reason string) shortcut.Shortcut {
|
||||
declared.Contract.Interface.Reason = reason
|
||||
return declared
|
||||
}
|
||||
|
||||
func wikiObjectResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"additionalProperties":true}`, description))}
|
||||
}
|
||||
|
||||
func wikiCollectionResult(collection, description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"有效结果数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Wiki 业务条目","additionalProperties":true}},"nextCursor":{"type":"string","description":"下一页游标"},"hasMore":{"type":"boolean","description":"服务端是否仍有下一页"}},"required":["count",%q],"additionalProperties":true}`,
|
||||
description, collection, description, collection))}
|
||||
}
|
||||
|
||||
func wikiCursorPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor", MetaPath: contract.PaginationMetaPath, EndpointExhaustedPath: contract.PaginationExhaustedPath, NextTokenPath: contract.PaginationNextTokenPath}
|
||||
}
|
||||
|
||||
func requireWikiResponse(data map[string]any, operation string) (map[string]any, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, wikiResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功")
|
||||
}
|
||||
if value, present := data["success"]; present {
|
||||
success, ok := value.(bool)
|
||||
if !ok {
|
||||
return nil, wikiResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
|
||||
}
|
||||
if !success {
|
||||
message := firstWikiString(data, "errorMsg", "message", "error")
|
||||
if message == "" {
|
||||
message = "服务明确返回 success=false"
|
||||
}
|
||||
return nil, wikiResponseError(operation, "remote_failure", message)
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireWikiWrite(data map[string]any, operation string) (map[string]any, error) {
|
||||
data, err := requireWikiResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if success, ok := data["success"].(bool); !ok || !success {
|
||||
return nil, wikiResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireWikiObject(data map[string]any, operation string) (map[string]any, error) {
|
||||
data, err := requireWikiResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if value, present := data[wrapper]; present {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, wikiResponseError(operation, "malformed_object", "响应业务对象缺失或畸形")
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
}
|
||||
if len(data) == 1 {
|
||||
return nil, wikiResponseError(operation, "missing_business_result", "响应没有可验证的业务对象")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireWikiCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) {
|
||||
data, err := requireWikiResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
containers := []map[string]any{data}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if value, present := data[wrapper]; present {
|
||||
inner, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil, wikiResponseError(operation, "malformed_envelope", fmt.Sprintf("响应 %s 字段不是对象", wrapper))
|
||||
}
|
||||
containers = append(containers, inner)
|
||||
}
|
||||
}
|
||||
for _, container := range containers {
|
||||
for _, key := range keys {
|
||||
value, present := container[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, nil, wikiResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
|
||||
}
|
||||
for index, item := range items {
|
||||
if _, ok := item.(map[string]any); !ok {
|
||||
return nil, nil, wikiResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index))
|
||||
}
|
||||
}
|
||||
return items, container, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, wikiResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段或内部错误投影成空结果")
|
||||
}
|
||||
|
||||
func projectWikiRows(items []any, aliases map[string][]string) []map[string]any {
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
source := item.(map[string]any)
|
||||
row := make(map[string]any)
|
||||
for canonical, candidates := range aliases {
|
||||
for _, candidate := range candidates {
|
||||
if value, ok := source[candidate]; ok && value != nil {
|
||||
row[canonical] = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func addWikiPagination(out, page map[string]any) {
|
||||
for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"pageToken", "nextCursor"}, {"hasMore", "hasMore"}, {"truncated", "truncated"}, {"totalCount", "totalCount"}, {"autoPageComplete", "autoPageComplete"}, {"autoPageStopReason", "autoPageStopReason"}, {"pagesFetched", "pagesFetched"}} {
|
||||
if value, ok := page[pair[0]]; ok && value != nil {
|
||||
out[pair[1]] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstWikiString(data map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nestedWikiString(data map[string]any, keys ...string) string {
|
||||
if value := firstWikiString(data, keys...); value != "" {
|
||||
return value
|
||||
}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if inner, ok := data[wrapper].(map[string]any); ok {
|
||||
if value := firstWikiString(inner, keys...); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func wikiStringInt(rt *shortcut.RuntimeContext, name string, fallback, min, max int) (int, error) {
|
||||
raw := rt.Str(name)
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < min || value > max {
|
||||
return 0, fmt.Errorf("--%s 必须是 %d-%d 之间的整数", name, min, max)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func wikiStringSliceFirst(rt *shortcut.RuntimeContext, primary string, aliases ...string) []string {
|
||||
if rt.Changed(primary) {
|
||||
return rt.StrSlice(primary)
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
if rt.Changed(alias) {
|
||||
values, _ := rt.Command().Flags().GetStringSlice(alias)
|
||||
return values
|
||||
}
|
||||
}
|
||||
return rt.StrSlice(primary)
|
||||
}
|
||||
|
||||
func wikiResponseError(operation, reason, message string) error {
|
||||
return apperrors.NewAPI(message, apperrors.WithOperation(operation), apperrors.WithOrigin("mcp"), apperrors.WithFailureStage("response_validation"), apperrors.WithRetryable(false), apperrors.WithReason(reason))
|
||||
}
|
||||
|
||||
func wikiReadSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||||
}
|
||||
func wikiWriteSafety(confirm bool) contract.SafetySpec {
|
||||
confirmation := "not_required"
|
||||
if confirm {
|
||||
confirmation = "user_required"
|
||||
}
|
||||
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: confirmation, Idempotency: "unknown"}
|
||||
}
|
||||
func wikiDeleteSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"}
|
||||
}
|
||||
|
||||
func wikiAutoPageFlags() []shortcut.Flag {
|
||||
const evidence = "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"
|
||||
return append([]shortcut.Flag{{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动沿游标取完所有页。" + evidence}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "自动翻页最多请求页数。" + evidence}}, shortcut.AutoPageControlFlags()...)
|
||||
}
|
||||
|
||||
func enableWikiAutoPage(item *shortcut.Shortcut) {
|
||||
item.Flags = append(item.Flags, wikiAutoPageFlags()...)
|
||||
item.Constraints = append(item.Constraints, shortcut.AutoPageControlConstraints()...)
|
||||
item.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.Bool("page-all") && rt.Int("page-limit") < 1 {
|
||||
return fmt.Errorf("--page-limit 必须大于 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type wikiPageFetcher func(cursor string, pageSize int) (map[string]any, error)
|
||||
|
||||
func collectWikiPages(rt *shortcut.RuntimeContext, operation string, pageSize int, keys []string, fetch wikiPageFetcher) ([]any, map[string]any, error) {
|
||||
if !rt.Bool("page-all") {
|
||||
data, err := fetch(rt.StrFirst("cursor", "page-token"), pageSize)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return requireWikiCollection(data, operation, keys...)
|
||||
}
|
||||
all := make([]any, 0)
|
||||
cursor := rt.StrFirst("cursor", "page-token")
|
||||
lastPage := map[string]any{}
|
||||
for pageNumber := 1; pageNumber <= rt.Int("page-limit"); pageNumber++ {
|
||||
requestSize := shortcut.AutoPageRequestSize(rt, pageSize, len(all))
|
||||
data, err := fetch(cursor, requestSize)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
items, page, err := requireWikiCollection(data, operation, keys...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
maxItems := rt.Int("max-items")
|
||||
if maxItems > 0 {
|
||||
remaining := maxItems - len(all)
|
||||
if len(items) > remaining {
|
||||
items = items[:remaining]
|
||||
}
|
||||
}
|
||||
all = append(all, items...)
|
||||
lastPage = page
|
||||
if maxItems > 0 && len(all) >= maxItems {
|
||||
lastPage["autoPageComplete"] = false
|
||||
lastPage["autoPageStopReason"] = "max_items"
|
||||
lastPage["pagesFetched"] = pageNumber
|
||||
return all, lastPage, nil
|
||||
}
|
||||
hasMore, present := page["hasMore"]
|
||||
if !present {
|
||||
return nil, nil, wikiResponseError(operation, "missing_has_more", "--page-all 要求每页响应提供 hasMore 布尔值")
|
||||
}
|
||||
more, ok := hasMore.(bool)
|
||||
if !ok {
|
||||
return nil, nil, wikiResponseError(operation, "malformed_has_more", "分页响应 hasMore 不是布尔值")
|
||||
}
|
||||
if !more {
|
||||
lastPage["autoPageComplete"] = true
|
||||
lastPage["pagesFetched"] = pageNumber
|
||||
return all, lastPage, nil
|
||||
}
|
||||
next := firstWikiString(page, "nextCursor", "nextToken", "nextPageToken", "pageToken")
|
||||
if next == "" {
|
||||
return nil, nil, wikiResponseError(operation, "missing_next_cursor", "hasMore=true 但响应缺少下一页游标")
|
||||
}
|
||||
if next == cursor {
|
||||
return nil, nil, wikiResponseError(operation, "stalled_cursor", "下一页游标未变化,已停止以避免死循环")
|
||||
}
|
||||
cursor = next
|
||||
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return nil, nil, wikiResponseError(operation, "page_limit_reached", "达到 --page-limit 时服务端仍有下一页;提高页数上限或使用返回游标续传")
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
var NodeList = readShortcut("+node-list", "严格分页列出知识库节点", "浏览知识库根目录或指定文件夹;只有显式 nodes:[] 才表示空目录,并完整保留 nextCursor/hasMore。", "nodes", "dws wiki +node-list --workspace <workspaceId> --format json", []shortcut.Flag{
|
||||
{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "父节点 ID"}, {Name: "limit", Type: shortcut.FlagInt, Default: "50", Desc: "每页数量 1-50"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
|
||||
}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "folder", Property: "folderId"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
items, page, err := collectWikiPages(rt, "wiki/list_nodes", rt.Int("limit"), []string{"nodes", "items", "list"}, func(cursor string, size int) (map[string]any, error) {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "pageSize": size}
|
||||
if rt.Changed("folder") {
|
||||
params["folderId"] = rt.Str("folder")
|
||||
}
|
||||
if cursor != "" {
|
||||
params["pageToken"] = cursor
|
||||
}
|
||||
return rt.CallMCPData("doc", "list_nodes", params)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodes := projectWikiRows(items, nodeAliases())
|
||||
out := map[string]any{"count": len(nodes), "nodes": nodes}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
})
|
||||
|
||||
func nodeAliases() map[string][]string {
|
||||
return map[string][]string{"nodeId": {"nodeId", "id", "dentryUuid", "fileId"}, "name": {"name", "title", "nodeName", "fileName"}, "type": {"type", "nodeType", "docType", "fileType"}, "contentType": {"contentType"}, "folderId": {"folderId", "parentId"}, "workspaceId": {"workspaceId", "spaceId"}, "url": {"docUrl", "url", "webUrl"}}
|
||||
}
|
||||
|
||||
var NodeGet = readShortcut("+node-get", "获取知识库节点详情", "已知节点 ID 或在线文档 URL 时读取元数据,并在节点信息之外统一返回文档/文件属性。", "", "dws wiki +node-get --node <nodeId> --format json", []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID 或 URL"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
object, err := requireWikiObject(data, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(object, "nodeId", "id", "fileId") == "" {
|
||||
return wikiResponseError("doc/get_document_info", "missing_node_id", "节点详情缺少 nodeId")
|
||||
}
|
||||
return rt.Output(object)
|
||||
})
|
||||
|
||||
var NodeSearch = readShortcut("+node-search", "严格搜索知识库节点", "在指定知识库内按关键词和扩展名搜索节点;零命中必须来自显式 documents:[]。", "nodes", "dws wiki +node-search --workspace <workspaceId> --query \"方案\" --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "关键词"}, {Name: "extensions", Type: shortcut.FlagStringSlice, Desc: "扩展名过滤"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-30"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceIds"}, {Name: "query", Property: "keyword"}, {Name: "extensions", Property: "extensions"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"workspaceIds": []string{rt.Str("workspace")}, "keyword": rt.Str("query"), "pageSize": rt.Int("limit")}
|
||||
if rt.Changed("extensions") {
|
||||
params["extensions"] = rt.StrSlice("extensions")
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["pageToken"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData("doc", "search_documents", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, page, err := requireWikiCollection(data, "doc/search_documents", "documents", "docs", "nodes", "items", "list")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodes := projectWikiRows(items, nodeAliases())
|
||||
out := map[string]any{"count": len(nodes), "nodes": nodes}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
})
|
||||
|
||||
var NodeCreate = writeShortcut("+node-create", "创建知识库节点并读回验证", "在知识库根目录或文件夹中创建文档、表格、白板、脑图或文件夹;取得 nodeId 并读回后才成功。", "dws wiki +node-create --workspace <workspaceId> --name \"新文档\" --format json", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "节点名称"}, {Name: "type", Type: shortcut.FlagString, Default: "adoc", Desc: "节点类型", Enum: []string{"adoc", "axls", "able", "appt", "adraw", "amind", "folder"}}, {Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "name", Property: "name"}, {Name: "type", Property: "type"}, {Name: "folder", Property: "folderId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "name": rt.Str("name"), "type": rt.Str("type")}
|
||||
if rt.Changed("folder") {
|
||||
params["folderId"] = rt.Str("folder")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/create_file", "arguments": params})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("doc", "create_file", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireWikiWrite(written, "doc/create_file")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := nestedWikiString(written, "nodeId", "fileId", "id")
|
||||
if id == "" {
|
||||
return wikiResponseError("doc/create_file", "missing_created_id", "创建响应没有 nodeId;远端效果未知")
|
||||
}
|
||||
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireWikiObject(verified, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(verified, "nodeId", "id", "fileId") != id {
|
||||
return wikiResponseError("doc/create_file", "readback_id_mismatch", "创建后读回节点 ID 不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": id, "node": verified})
|
||||
})
|
||||
|
||||
var NodeCopy = writeShortcut("+node-copy", "复制知识库节点并读回验证", "复制现有在线节点到目标知识库/文件夹;高风险确认后要求新 nodeId 并读取副本元数据。", "dws wiki +node-copy --workspace <workspaceId> --node <nodeId> --format json", shortcut.RiskHighWrite, contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "non_idempotent"}, []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "源节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "nodeId": rt.Str("node")}
|
||||
if rt.Changed("folder") {
|
||||
params["targetFolderId"] = rt.Str("folder")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/copy_document", "arguments": params})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("doc", "copy_document", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireWikiWrite(written, "doc/copy_document")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := nestedWikiString(written, "nodeId", "fileId", "id")
|
||||
if id == "" {
|
||||
return wikiResponseError("doc/copy_document", "missing_created_id", "复制响应没有新 nodeId;远端效果未知")
|
||||
}
|
||||
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireWikiObject(verified, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(verified, "nodeId", "id", "fileId") != id {
|
||||
return wikiResponseError("doc/copy_document", "readback_id_mismatch", "复制后读回节点 ID 不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "sourceNodeId": rt.Str("node"), "nodeId": id, "copy": verified})
|
||||
})
|
||||
|
||||
func executeMove(rt *shortcut.RuntimeContext, toDrive bool) error {
|
||||
preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preflight, err = requireWikiObject(preflight, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeWorkspace := firstWikiString(preflight, "workspaceId", "spaceId")
|
||||
params := map[string]any{"nodeId": rt.Str("node")}
|
||||
if !toDrive {
|
||||
params["workspaceId"] = rt.Str("workspace")
|
||||
}
|
||||
if rt.Changed("folder") {
|
||||
params["targetFolderId"] = rt.Str("folder")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/move_document", "arguments": params, "target": preflight})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("doc", "move_document", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = requireWikiWrite(written, "doc/move_document"); err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireWikiObject(verified, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(verified, "nodeId", "id", "fileId") != rt.Str("node") {
|
||||
return wikiResponseError("doc/move_document", "readback_id_mismatch", "移动后读回节点 ID 不一致")
|
||||
}
|
||||
afterWorkspace := firstWikiString(verified, "workspaceId", "spaceId")
|
||||
if toDrive {
|
||||
if beforeWorkspace == "" || afterWorkspace == "" || beforeWorkspace == afterWorkspace {
|
||||
return wikiResponseError("doc/move_document", "drive_move_not_verified", "移动到我的文档后 workspace 未发生可验证变化")
|
||||
}
|
||||
} else if afterWorkspace != rt.Str("workspace") {
|
||||
return wikiResponseError("doc/move_document", "workspace_readback_mismatch", "移动后读回的目标知识库不一致")
|
||||
}
|
||||
if rt.Changed("folder") && firstWikiString(verified, "folderId", "parentId") != rt.Str("folder") {
|
||||
return wikiResponseError("doc/move_document", "folder_readback_mismatch", "移动后读回的目标文件夹不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "node": verified})
|
||||
}
|
||||
|
||||
var Move = writeShortcut("+move", "移动节点到知识库并读回验证", "将 Wiki 节点或我的文档在线节点移动到目标知识库/文件夹;同一入口覆盖库内移动与在线文档入库场景。", "dws wiki +move --workspace <workspaceId> --node <nodeId> --format json", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, false) })
|
||||
|
||||
var MoveToDrive = writeShortcut("+move-to-drive", "移动 Wiki 节点到我的文档", "将 Wiki 在线节点同步移动到我的文档或指定文件夹,并以元数据读回证明任务完成。", "dws wiki +move-to-drive --node <nodeId> --format json", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "Wiki 节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "我的文档目标文件夹 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, true) })
|
||||
|
||||
var NodeDelete = writeShortcut("+node-delete", "删除知识库节点", "明确确认后将节点移入回收站;先读取目标,且删除响应必须提供 success=true 终态证据。", "dws wiki +node-delete --workspace <workspaceId> --node <nodeId> --format json", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID,用于确认影响范围"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preflight, err = requireWikiObject(preflight, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if workspace := firstWikiString(preflight, "workspaceId", "spaceId"); workspace != "" && workspace != rt.Str("workspace") {
|
||||
return wikiResponseError("doc/delete_document", "workspace_preflight_mismatch", "节点不属于请求确认的知识库")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/delete_document", "target": preflight})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("doc", "delete_document", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = requireWikiWrite(written, "doc/delete_document"); err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "deleted": true})
|
||||
})
|
||||
|
||||
var FeedList = readShortcut("+feed-list", "严格分页列出知识库动态", "查看谁在何时创建、更新或评论了知识库内容;严格验证 feeds 数组并保留游标。", "feeds", "dws wiki +feed-list --workspace <workspaceId> --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-20"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, {Name: "exclude-file", Type: shortcut.FlagBool, Desc: "排除普通文件动态"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "cursor", Property: "nextToken"}, {Name: "exclude-file", Property: "excludeFile"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
items, page, err := collectWikiPages(rt, "wiki/list_workspace_feeds", rt.Int("limit"), []string{"feeds", "items", "list"}, func(cursor string, size int) (map[string]any, error) {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": size}
|
||||
if cursor != "" {
|
||||
params["nextToken"] = cursor
|
||||
}
|
||||
if rt.Changed("exclude-file") {
|
||||
params["excludeFile"] = rt.Bool("exclude-file")
|
||||
}
|
||||
return rt.CallMCPData("wiki", "list_workspace_feeds", params)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
feeds := projectWikiRows(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}})
|
||||
out := map[string]any{"count": len(feeds), "feeds": feeds}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
})
|
||||
|
||||
func init() {
|
||||
Move.Aliases = []string{"+node-move"}
|
||||
for _, item := range []*shortcut.Shortcut{&NodeList, &FeedList} {
|
||||
enableWikiAutoPage(item)
|
||||
}
|
||||
for _, item := range []*shortcut.Shortcut{&NodeList, &NodeSearch, &FeedList} {
|
||||
item.Contract.Pagination = wikiCursorPagination()
|
||||
}
|
||||
shortcut.Register(NodeList, NodeGet, NodeSearch, NodeCreate, NodeCopy, Move, MoveToDrive, NodeDelete, FeedList)
|
||||
_ = fmt.Sprintf
|
||||
_ = output.RolloutUnifiedActive
|
||||
}
|
||||
@@ -18,11 +18,11 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSpaceListProjectWikiSpacesShape guards against projection-data-loss:
|
||||
// TestCrossPlatformCoverageWikiSpaceListShape guards against projection-data-loss:
|
||||
// list_wikiSpaces / search_wikiSpaces nest the list under result.wikiSpaces;
|
||||
// the resolver must probe "wikiSpaces" or +space-list / +space-search silently
|
||||
// return empty despite the backend returning spaces.
|
||||
func TestSpaceListProjectWikiSpacesShape(t *testing.T) {
|
||||
func TestCrossPlatformCoverageWikiSpaceListShape(t *testing.T) {
|
||||
const raw = `{"result":{"hasMore":false,"wikiSpaces":[
|
||||
{"workspaceId":"w1","name":"R&D wiki"},
|
||||
{"workspaceId":"w2","name":"product wiki"}
|
||||
@@ -31,19 +31,47 @@ func TestSpaceListProjectWikiSpacesShape(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if spaces := spaceListProject(data); len(spaces) != 2 {
|
||||
items, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId"}, "name": {"name"}})
|
||||
if len(spaces) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: result.wikiSpaces has 2 entries, projection returned %d (%v)", len(spaces), spaces)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpaceListProjectTopLevelWikiSpaces covers the already-unwrapped shape.
|
||||
func TestSpaceListProjectTopLevelWikiSpaces(t *testing.T) {
|
||||
func TestCrossPlatformCoverageWikiSpaceListTopLevelShape(t *testing.T) {
|
||||
const raw = `{"wikiSpaces":[{"workspaceId":"w1","name":"R&D wiki"}]}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if spaces := spaceListProject(data); len(spaces) != 1 {
|
||||
items, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId"}, "name": {"name"}})
|
||||
if len(spaces) != 1 {
|
||||
t.Fatalf("top-level wikiSpaces: want 1, got %d (%v)", len(spaces), spaces)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiCollectionsRejectFalseEmptySuccess(t *testing.T) {
|
||||
for name, data := range map[string]map[string]any{
|
||||
"missing": {"success": true, "hasMore": false},
|
||||
"malformed": {"success": true, "wikiSpaces": map[string]any{}},
|
||||
"bad item": {"success": true, "wikiSpaces": []any{"not-an-object"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces"); err == nil {
|
||||
t.Fatal("malformed response was accepted as an empty success")
|
||||
}
|
||||
})
|
||||
}
|
||||
items, _, err := requireWikiCollection(map[string]any{"success": true, "wikiSpaces": []any{}}, "wiki/list_wikiSpaces", "wikiSpaces")
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("explicit empty array must remain valid: items=%v err=%v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
+209
-316
@@ -1,344 +1,237 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package wiki declares high-fidelity shortcuts for the DingTalk wiki
|
||||
// (knowledge base) service: space management, member management and node
|
||||
// management. Tool names and parameters mirror internal/helpers/wiki.go.
|
||||
// Package wiki declares reviewed, truth-preserving shortcuts for Wiki spaces,
|
||||
// members, nodes, and activity feeds.
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// ── space (知识库) ────────────────────────────────────────────
|
||||
var collectionAvoid = []string{"需要原始 MCP 响应或未公开底层参数时改用对应原子命令;缺失业务数组不是合法空结果"}
|
||||
|
||||
// SpaceCreate → create_wikiSpace
|
||||
// SpaceGet → get_wikiSpace
|
||||
// SpaceList → list_wikiSpaces
|
||||
var SpaceList = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+space-list",
|
||||
Product: "wiki",
|
||||
Description: "列出组织 / 个人知识库",
|
||||
Intent: "当你想浏览自己有权限访问的知识库、拿到目标知识库的 workspaceId 却不确定具体名称时使用;可按类型(组织知识库或我的知识库)分页列出,返回知识库列表,是定位知识库的常用入口。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型: orgWikiSpace(默认) / myWikiSpace", Enum: []string{"orgWikiSpace", "myWikiSpace"}},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页数量 1-50 (默认 20)"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (首页留空)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws wiki +space-list`,
|
||||
`dws wiki +space-list --type myWikiSpace`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Changed("type") {
|
||||
params["wikiSpaceType"] = rt.Str("type")
|
||||
func readShortcut(command, description, intent, collection, example string, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
|
||||
result := wikiObjectResult(description)
|
||||
if collection != "" {
|
||||
result = wikiCollectionResult(collection, description)
|
||||
}
|
||||
return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: shortcut.RiskRead, Safety: wikiReadSafety(), Contract: wikiContract(command, description, intent, collectionAvoid, []string{example}, result, nil, params...), Flags: flags, Execute: execute}
|
||||
}
|
||||
|
||||
func writeShortcut(command, description, intent, example string, risk shortcut.Risk, safety contract.SafetySpec, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
|
||||
return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: risk, Safety: safety, Contract: wikiContract(command, description, intent, []string{"只需读取或影响范围未确认时不要执行写操作"}, []string{example}, wikiObjectResult(description), nil, params...), Flags: flags, Execute: execute}
|
||||
}
|
||||
|
||||
var SpaceList = readShortcut("+space-list", "严格分页列出知识库", "浏览有权访问的组织或个人知识库,并保留服务端分页证据;只有显式 wikiSpaces:[] 才是空结果。", "spaces", "dws wiki +space-list --limit 20 --format json", []shortcut.Flag{
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型", Enum: []string{"orgWikiSpace", "myWikiSpace"}},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页数量 1-50(默认 20)"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
|
||||
}, []contract.ParamDecl{{Name: "type", Property: "wikiSpaceType"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
pageSize, err := wikiStringInt(rt, "limit", 20, 1, 50)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, page, err := collectWikiPages(rt, "wiki/list_wikiSpaces", pageSize, []string{"wikiSpaces", "spaces"}, func(cursor string, size int) (map[string]any, error) {
|
||||
params := map[string]any{"wikiSpaceType": rt.Str("type"), "pageSize": size}
|
||||
if cursor != "" {
|
||||
params["pageToken"] = cursor
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["pageSize"] = rt.Str("limit")
|
||||
return rt.CallMCPData("wiki", "list_wikiSpaces", params)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId", "spaceId", "id"}, "name": {"name", "spaceName", "title"}, "description": {"description", "desc"}, "createTime": {"createTime", "createdAt"}, "url": {"spaceUrl", "url"}})
|
||||
out := map[string]any{"count": len(spaces), "spaces": spaces}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
})
|
||||
|
||||
var SpaceSearch = wikiWithInterfaceReason(readShortcut("+space-search", "严格搜索知识库", "按名称关键词定位知识库;严格验证搜索数组,避免内部异常被误报为零命中。", "spaces", "dws wiki +space-search --query \"产品文档\" --format json", []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Required: true, Desc: "搜索关键词"}, {Name: "limit", Type: shortcut.FlagString, Desc: "返回数量 1-20(默认 10)"},
|
||||
}, []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "limit", Property: "limit"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
pageSize, err := wikiStringInt(rt, "limit", 10, 1, 20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("wiki", "search_wikiSpaces", map[string]any{"keyword": rt.Str("query"), "pageSize": pageSize})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, page, err := requireWikiCollection(data, "wiki/search_wikiSpaces", "wikiSpaces", "spaces")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId", "spaceId", "id"}, "name": {"name", "spaceName", "title"}, "description": {"description", "desc"}, "url": {"spaceUrl", "url"}})
|
||||
out := map[string]any{"count": len(spaces), "spaces": spaces}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
}), wikiSpaceSearchCompositeReason)
|
||||
|
||||
var SpaceGet = readShortcut("+space-get", "获取知识库详情", "已知 workspace ID 或知识库 URL 时读取并验证空间详情。", "", "dws wiki +space-get --workspace <workspaceId> --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
object, err := requireWikiObject(data, "wiki/get_wikiSpace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(object, "workspaceId", "spaceId", "id") == "" {
|
||||
return wikiResponseError("wiki/get_wikiSpace", "missing_workspace_id", "空间详情缺少 workspaceId")
|
||||
}
|
||||
return rt.Output(object)
|
||||
})
|
||||
|
||||
var SpaceCreate = writeShortcut("+space-create", "创建知识库并读回验证", "创建新的知识库容器;必须取得 workspaceId 并通过详情读回后才报告成功。", "dws wiki +space-create --name \"产品文档库\" --format json", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "name", Type: shortcut.FlagString, Required: true, Desc: "知识库名称,不超过 32 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "desc", Type: shortcut.FlagString, Desc: "知识库描述,不超过 500 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "icon", Type: shortcut.FlagString, Desc: "图标标识"}}, []contract.ParamDecl{{Name: "name", Property: "name"}, {Name: "desc", Property: "description"}, {Name: "icon", Property: "icon"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"name": rt.Str("name")}
|
||||
if rt.Changed("desc") {
|
||||
params["description"] = rt.Str("desc")
|
||||
}
|
||||
if rt.Changed("icon") {
|
||||
params["icon"] = rt.Str("icon")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/create_wikiSpace", "arguments": params})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("wiki", "create_wikiSpace", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireWikiWrite(written, "wiki/create_wikiSpace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := nestedWikiString(written, "workspaceId", "spaceId", "id")
|
||||
if id == "" {
|
||||
return wikiResponseError("wiki/create_wikiSpace", "missing_created_id", "创建响应没有 workspaceId;远端效果未知")
|
||||
}
|
||||
verified, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireWikiObject(verified, "wiki/get_wikiSpace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(verified, "workspaceId", "spaceId", "id") != id {
|
||||
return wikiResponseError("wiki/create_wikiSpace", "readback_id_mismatch", "创建后读回的 workspaceId 不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "workspaceId": id, "space": verified})
|
||||
})
|
||||
|
||||
var DeleteSpace = writeShortcut("+delete-space", "删除知识库", "用户明确确认后将整个知识库移入回收站;删除前读取目标,删除响应必须有 success=true。", "dws wiki +delete-space --workspace <workspaceId> --format json", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
preflight, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preflight, err = requireWikiObject(preflight, "wiki/get_wikiSpace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/delete_wikiSpace", "target": preflight})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("wiki", "delete_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireWikiWrite(written, "wiki/delete_wikiSpace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "workspaceId": rt.Str("workspace"), "deleted": true})
|
||||
})
|
||||
|
||||
func memberFlags(withRole bool) []shortcut.Flag {
|
||||
flags := []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "users", Type: shortcut.FlagStringSlice, Required: true, Desc: "用户 userId,最多 30 个", Aliases: []string{"user"}, AliasesVisible: true}}
|
||||
if withRole {
|
||||
flags = append(flags, shortcut.Flag{Name: "role", Type: shortcut.FlagString, Required: true, Desc: "角色", Enum: []string{"MANAGER", "EDITOR", "DOWNLOADER", "READER"}})
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func memberWrite(command, tool, description, intent, example string, withRole bool) shortcut.Shortcut {
|
||||
params := []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "users", Property: "userIds"}}
|
||||
if withRole {
|
||||
params = append(params, contract.ParamDecl{Name: "role", Property: "roleId"})
|
||||
}
|
||||
return writeShortcut(command, description, intent, example, shortcut.RiskWrite, wikiWriteSafety(false), memberFlags(withRole), params, func(rt *shortcut.RuntimeContext) error {
|
||||
users := wikiStringSliceFirst(rt, "users", "user")
|
||||
if len(users) == 0 || len(users) > 30 {
|
||||
return fmt.Errorf("--users 必须包含 1-30 个 userId")
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["pageToken"] = rt.Str("cursor")
|
||||
args := map[string]any{"workspaceId": rt.Str("workspace"), "userIds": users}
|
||||
if withRole {
|
||||
args["roleId"] = strings.ToUpper(rt.Str("role"))
|
||||
}
|
||||
data, err := rt.CallMCPData("wiki", "list_wikiSpaces", params)
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/" + tool, "arguments": args})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("wiki", tool, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaces := spaceListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(spaces), "spaces": spaces})
|
||||
},
|
||||
}
|
||||
|
||||
// spaceListProject reshapes list_wikiSpaces into a clean space list
|
||||
// ({workspaceId, name, description, createTime}) — output-projection fidelity
|
||||
// for clean output. The list container and per-item field names are probed defensively
|
||||
// across candidate keys, so an unrecognized shape yields an empty list.
|
||||
func spaceListProject(data map[string]any) []map[string]any {
|
||||
raw := wikiSpaceRawList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v := wikiSpaceFirst(m, "workspaceId", "workspace_id", "spaceId", "space_id", "id"); v != nil {
|
||||
row["workspaceId"] = v
|
||||
}
|
||||
if v := wikiSpaceFirst(m, "name", "title", "spaceName"); v != nil {
|
||||
row["name"] = v
|
||||
}
|
||||
if v := wikiSpaceFirst(m, "description", "desc"); v != nil {
|
||||
row["description"] = v
|
||||
}
|
||||
if v := wikiSpaceFirst(m, "createTime", "create_time", "gmtCreate", "createdAt"); v != nil {
|
||||
row["createTime"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wikiSpaceRawList locates the space array across candidate container keys,
|
||||
// tolerating a nested {result|data:{list|items|spaces}} wrapper.
|
||||
func wikiSpaceRawList(data map[string]any) []any {
|
||||
// list_wikiSpaces / search_wikiSpaces nest the space list under
|
||||
// result.wikiSpaces (or a top-level wikiSpaces once unwrapped); "wikiSpaces"
|
||||
// MUST be probed or +space-list / +space-search silently return empty.
|
||||
for _, k := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "workspaces"} {
|
||||
if arr, ok := data[k].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := data[k].(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "wikiSpaces", "spaces", "workspaces", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wikiSpaceFirst returns the first present value among candidate keys.
|
||||
func wikiSpaceFirst(m map[string]any, keys ...string) any {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpaceSearch → search_wikiSpaces
|
||||
var SpaceSearch = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+space-search",
|
||||
Product: "wiki",
|
||||
Description: "搜索知识库",
|
||||
Intent: "当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "wiki",
|
||||
Name: "shortcut_space_search",
|
||||
CanonicalPath: "wiki.shortcut_space_search",
|
||||
CLIPath: "wiki +space-search",
|
||||
PrimaryCLIPath: "wiki +space-search",
|
||||
},
|
||||
Description: "搜索知识库",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "搜索知识库",
|
||||
UseWhen: []string{"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws wiki +space-search --query \"产品文档\""},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "返回数量 1-20 (默认 10)"},
|
||||
},
|
||||
Tips: []string{`dws wiki +space-search --query "产品文档"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"keyword": rt.Str("query")}
|
||||
if rt.Changed("limit") {
|
||||
params["pageSize"] = rt.Str("limit")
|
||||
}
|
||||
data, err := rt.CallMCPData("wiki", "search_wikiSpaces", params)
|
||||
written, err = requireWikiWrite(written, "wiki/"+tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaces := spaceListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(spaces), "spaces": spaces})
|
||||
},
|
||||
return rt.Output(map[string]any{
|
||||
"success": true, "workspaceId": rt.Str("workspace"), "userCount": len(users), "operation": tool,
|
||||
"verifiedBy": "write_terminal_success",
|
||||
"verification": map[string]any{
|
||||
"status": "terminal_response_only",
|
||||
"readbackAvailable": false,
|
||||
"reason": "member_list_is_capped_and_has_no_cursor",
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// SpaceDelete → delete_wikiSpace
|
||||
// ── member (知识库成员) ───────────────────────────────────────
|
||||
var MemberAdd = memberWrite("+member-add", "add_member", "添加知识库成员", "向知识库授予一个或多个用户容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", "dws wiki +member-add --workspace <workspaceId> --users <userId> --role READER --format json", true)
|
||||
var MemberUpdate = memberWrite("+member-update", "update_member", "更新知识库成员角色", "调整已有成员的知识库容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", "dws wiki +member-update --workspace <workspaceId> --users <userId> --role EDITOR --format json", true)
|
||||
var MemberRemove = memberWrite("+member-remove", "remove_member", "移除知识库成员", "移除一个或多个用户的知识库容器级访问;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", "dws wiki +member-remove --workspace <workspaceId> --users <userId> --format json", false)
|
||||
|
||||
// MemberAdd → add_member
|
||||
// MemberUpdate → update_member
|
||||
// MemberList → list_member
|
||||
// MemberRemove → remove_member
|
||||
// ── node (知识库节点,路由到 doc MCP server) ──────────────────
|
||||
|
||||
// NodeList → list_nodes (doc)
|
||||
var NodeList = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+node-list",
|
||||
Product: "doc",
|
||||
Description: "列出知识库节点",
|
||||
Intent: "当你要浏览某个知识库的目录结构、查看某文件夹下有哪些文档/子文件夹并拿到它们的 nodeId 时使用;输入 workspace(可选父节点 folder),分页返回该层级的节点列表,是逐层进入知识库定位文档的常用方式。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "父节点 nodeId (不传则列出根目录)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
|
||||
},
|
||||
Tips: []string{`dws wiki +node-list --workspace <workspaceId> --folder <parentNodeId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace")}
|
||||
if rt.Changed("folder") {
|
||||
params["folderId"] = rt.Str("folder")
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["pageSize"] = rt.Int("limit")
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["pageToken"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData("wiki", "list_nodes", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodes := nodeListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes})
|
||||
},
|
||||
}
|
||||
|
||||
// nodeListProject reshapes list_nodes into a clean node list (name/nodeId/type)
|
||||
// — clean output projection. Container and field keys are probed
|
||||
// defensively across candidate aliases; an unrecognized shape yields an empty list.
|
||||
func nodeListProject(data map[string]any) []map[string]any {
|
||||
raw := nodeListRawList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v := nodeListFirst(m, "name", "title", "nodeName"); v != nil {
|
||||
row["name"] = v
|
||||
}
|
||||
if v := nodeListFirst(m, "nodeId", "node_id", "id", "uuid", "dentryUuid"); v != nil {
|
||||
row["nodeId"] = v
|
||||
}
|
||||
if v := nodeListFirst(m, "type", "nodeType", "docType", "fileType"); v != nil {
|
||||
row["type"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
var MemberList = readShortcut("+member-list", "严格列出知识库成员", "列出知识库成员及角色;后端不提供可续游标且单次真实上限为 50,不伪造 page-all。", "members", "dws wiki +member-list --workspace <workspaceId> --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "30", Desc: "返回上限 1-50"}, {Name: "filter-role", Type: shortcut.FlagStringSlice, Desc: "角色过滤"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "filter-role", Property: "filterRoleIds"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("limit") < 1 || rt.Int("limit") > 50 {
|
||||
return fmt.Errorf("--limit 必须在 1-50 之间;服务端不支持超过 50 或游标续页")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nodeListRawList locates the node array across candidate container keys,
|
||||
// tolerating a nested {result|data:{list|items|nodes}} wrapper.
|
||||
func nodeListRawList(data map[string]any) []any {
|
||||
for _, k := range []string{"result", "data", "list", "items", "nodes"} {
|
||||
if arr, ok := data[k].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := data[k].(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "nodes", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": rt.Int("limit")}
|
||||
if rt.Changed("filter-role") {
|
||||
params["filterRoleIds"] = rt.StrSlice("filter-role")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nodeListFirst returns the first present value among candidate keys.
|
||||
func nodeListFirst(m map[string]any, keys ...string) any {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v
|
||||
}
|
||||
data, err := rt.CallMCPData("wiki", "list_member", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
items, page, err := requireWikiCollection(data, "wiki/list_member", "members")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members := projectWikiRows(items, map[string][]string{"id": {"id", "userId"}, "name": {"name", "nick"}, "role": {"role", "roleId"}, "type": {"type"}, "outer": {"outer"}})
|
||||
out := map[string]any{"count": len(members), "members": members}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
})
|
||||
|
||||
// NodeCreate → create_file (doc)
|
||||
// NodeCopy → copy_document (doc)
|
||||
var NodeCopy = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+node-copy",
|
||||
Product: "doc",
|
||||
Description: "复制知识库节点",
|
||||
Intent: "当你想基于已有文档/文件夹快速生成一份副本(如用模板起草新文档、留档备份)时使用;指定源 node 和目标 folder,会实际在知识库中复制出一个新节点,原节点保持不变。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true},
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId (不传则复制到根目录)"},
|
||||
},
|
||||
Tips: []string{`dws wiki +node-copy --workspace <workspaceId> --node <nodeId> --folder <targetFolderId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"nodeId": rt.Str("node"),
|
||||
"workspaceId": rt.Str("workspace"),
|
||||
}
|
||||
if rt.Changed("folder") {
|
||||
params["targetFolderId"] = rt.Str("folder")
|
||||
}
|
||||
return rt.CallMCP("copy_document", params)
|
||||
},
|
||||
}
|
||||
|
||||
// NodeMove → move_document (doc)
|
||||
var NodeMove = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+node-move",
|
||||
Product: "doc",
|
||||
Description: "移动知识库节点",
|
||||
Intent: "当你要重新整理知识库目录、把某个文档或文件夹从当前位置挪到另一个文件夹(或根目录)下时使用;指定源 node 和目标 folder,会实际改变该节点在知识库中的所属位置。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true},
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId (不传则移动到根目录)"},
|
||||
},
|
||||
Tips: []string{`dws wiki +node-move --workspace <workspaceId> --node <nodeId> --folder <targetFolderId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"nodeId": rt.Str("node"),
|
||||
"workspaceId": rt.Str("workspace"),
|
||||
}
|
||||
if rt.Changed("folder") {
|
||||
params["targetFolderId"] = rt.Str("folder")
|
||||
}
|
||||
return rt.CallMCP("move_document", params)
|
||||
},
|
||||
}
|
||||
|
||||
// NodeDelete → delete_document (doc)
|
||||
// NodeSearch → search_documents (doc)
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
SpaceList,
|
||||
SpaceSearch,
|
||||
NodeList,
|
||||
NodeCopy,
|
||||
NodeMove,
|
||||
)
|
||||
DeleteSpace.Aliases = []string{"+space-delete"}
|
||||
SpaceCreate.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
if len([]rune(rt.Str("name"))) > 32 {
|
||||
return fmt.Errorf("--name 不能超过 32 个字符")
|
||||
}
|
||||
if len([]rune(rt.Str("desc"))) > 500 {
|
||||
return fmt.Errorf("--desc 不能超过 500 个字符")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
SpaceCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"name", "desc"}, Description: "--name 不超过 32 个字符;--desc 不超过 500 个字符"}}
|
||||
enableWikiAutoPage(&SpaceList)
|
||||
SpaceList.Contract.Pagination = wikiCursorPagination()
|
||||
shortcut.Register(SpaceList, SpaceSearch, SpaceGet, SpaceCreate, DeleteSpace, MemberAdd, MemberUpdate, MemberList, MemberRemove)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type wikiCoverageCall struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type wikiCoverageCaller struct {
|
||||
responses map[string][]string
|
||||
errors map[string][]error
|
||||
indexes map[string]int
|
||||
calls []wikiCoverageCall
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *wikiCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
key := product + "/" + tool
|
||||
c.calls = append(c.calls, wikiCoverageCall{product: product, tool: tool, args: args})
|
||||
if c.indexes == nil {
|
||||
c.indexes = map[string]int{}
|
||||
}
|
||||
index := c.indexes[key]
|
||||
c.indexes[key]++
|
||||
if sequence := c.errors[key]; index < len(sequence) && sequence[index] != nil {
|
||||
return nil, sequence[index]
|
||||
}
|
||||
sequence := c.responses[key]
|
||||
if len(sequence) == 0 {
|
||||
return nil, fmt.Errorf("unexpected MCP call %s", key)
|
||||
}
|
||||
if index >= len(sequence) {
|
||||
index = len(sequence) - 1
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: sequence[index]}}}, nil
|
||||
}
|
||||
|
||||
func (c *wikiCoverageCaller) Format() string { return "json" }
|
||||
func (c *wikiCoverageCaller) DryRun() bool { return c.dryRun }
|
||||
func (c *wikiCoverageCaller) Fields() string { return "" }
|
||||
func (c *wikiCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runWikiCoverageCLI(t *testing.T, caller *wikiCoverageCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.PersistentFlags().String("jq", "", "")
|
||||
root.PersistentFlags().String("fields", "", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
stdout := &bytes.Buffer{}
|
||||
root.SetOut(stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs(append([]string{"wiki"}, args...))
|
||||
executed, err := root.ExecuteC()
|
||||
if err == nil {
|
||||
if _, _, emitErr := output.EmitStoredResult(executed); emitErr != nil {
|
||||
err = emitErr
|
||||
}
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatalf("decode output %q: %v", stdout.String(), decodeErr)
|
||||
}
|
||||
if data, ok := payload["data"].(map[string]any); ok {
|
||||
return data, err
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiSpaceWorkflows(t *testing.T) {
|
||||
t.Run("list default string limit", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_wikiSpaces": {`{"success":true,"result":{"wikiSpaces":[{"workspaceId":"w1","name":"Docs"}],"hasMore":false}}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-list")
|
||||
if err != nil || out["count"] != float64(1) || caller.calls[0].args["pageSize"] != 20 {
|
||||
t.Fatalf("list output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list explicit legacy string limit and cursor", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":false}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--type", "myWikiSpace", "--limit", "7", "--page-token", "next")
|
||||
if err != nil || out["count"] != float64(0) || caller.calls[0].args["pageSize"] != 7 || caller.calls[0].args["pageToken"] != "next" {
|
||||
t.Fatalf("list output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
for _, value := range []string{"bad", "0", "51"} {
|
||||
t.Run("list rejects "+value, func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", value); err == nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("limit %q err=%v calls=%#v", value, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("search parses string limit", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/search_wikiSpaces": {`{"success":true,"wikiSpaces":[{"spaceId":"w2","spaceName":"Plan"}]}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-search", "--query", "Plan", "--limit", "12")
|
||||
if err != nil || out["count"] != float64(1) || len(caller.calls) != 1 {
|
||||
t.Fatalf("search output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
if got := caller.calls[0]; got.product != "wiki" || got.tool != "search_wikiSpaces" || len(got.args) != 2 || got.args["keyword"] != "Plan" || got.args["pageSize"] != 12 {
|
||||
t.Fatalf("search call = %#v, want exact keyword/pageSize request", got)
|
||||
}
|
||||
if _, exists := caller.calls[0].args["query"]; exists {
|
||||
t.Fatalf("search request leaked compatibility property query: %#v", caller.calls[0].args)
|
||||
}
|
||||
if _, exists := caller.calls[0].args["limit"]; exists {
|
||||
t.Fatalf("search request leaked compatibility property limit: %#v", caller.calls[0].args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get requires business id", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"name":"missing id"}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+space-get", "--workspace", "w"); err == nil {
|
||||
t.Fatal("space without id succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create dry-run", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-create", "--name", "Docs", "--desc", "D", "--icon", "I", "--dry-run")
|
||||
if err != nil || out["executed"] != false || len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create and exact readback", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/create_wikiSpace": {`{"success":true,"result":{"workspaceId":"w3"}}`},
|
||||
"wiki/get_wikiSpace": {`{"success":true,"data":{"workspaceId":"w3","name":"Docs"}}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-create", "--name", "Docs")
|
||||
if err != nil || out["workspaceId"] != "w3" || len(caller.calls) != 2 {
|
||||
t.Fatalf("create output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete preflight dry-run and terminal", func(t *testing.T) {
|
||||
dry := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w4","name":"Docs"}`}}}
|
||||
out, err := runWikiCoverageCLI(t, dry, "+delete-space", "--workspace", "w4", "--dry-run", "--yes")
|
||||
if err != nil || out["executed"] != false || len(dry.calls) != 1 {
|
||||
t.Fatalf("delete dry-run output=%#v err=%v calls=%#v", out, err, dry.calls)
|
||||
}
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/get_wikiSpace": {`{"workspaceId":"w4","name":"Docs"}`},
|
||||
"wiki/delete_wikiSpace": {`{"success":true}`},
|
||||
}}
|
||||
out, err = runWikiCoverageCLI(t, caller, "+space-delete", "--workspace", "w4", "--yes")
|
||||
if err != nil || out["deleted"] != true || len(caller.calls) != 2 {
|
||||
t.Fatalf("delete output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
for _, args := range [][]string{{"--name", strings.Repeat("界", 33)}, {"--name", "x", "--desc", strings.Repeat("界", 501)}} {
|
||||
caller := &wikiCoverageCaller{}
|
||||
if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-create"}, args...)...); err == nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid create args succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiMemberWritesUseTerminalEvidenceOnly(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
command string
|
||||
tool string
|
||||
extra []string
|
||||
}{
|
||||
{command: "+member-add", tool: "add_member", extra: []string{"--role", "READER"}},
|
||||
{command: "+member-update", tool: "update_member", extra: []string{"--role", "EDITOR"}},
|
||||
{command: "+member-remove", tool: "remove_member"},
|
||||
} {
|
||||
t.Run(tc.command, func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/" + tc.tool: {`{"success":true}`}}}
|
||||
args := append([]string{tc.command, "--workspace", "w", "--users", "u1,u2"}, tc.extra...)
|
||||
out, err := runWikiCoverageCLI(t, caller, args...)
|
||||
verification, _ := out["verification"].(map[string]any)
|
||||
if err != nil || out["verifiedBy"] != "write_terminal_success" || verification["readbackAvailable"] != false || len(caller.calls) != 1 {
|
||||
t.Fatalf("member output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
if caller.calls[0].tool == "list_member" {
|
||||
t.Fatal("member write used capped list as readback")
|
||||
}
|
||||
if tc.tool != "remove_member" && caller.calls[0].args["roleId"] != strings.ToUpper(tc.extra[1]) {
|
||||
t.Fatalf("role was not normalized: %#v", caller.calls[0].args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("dry-run has no write", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER", "--dry-run")
|
||||
if err != nil || out["executed"] != false || len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects missing terminal evidence", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/add_member": {`{"result":{"accepted":true}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER"); err == nil {
|
||||
t.Fatal("member write without success=true succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
many := make([]string, 31)
|
||||
for index := range many {
|
||||
many[index] = fmt.Sprintf("u%d", index)
|
||||
}
|
||||
caller := &wikiCoverageCaller{}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+member-remove", "--workspace", "w", "--users", strings.Join(many, ",")); err == nil || len(caller.calls) != 0 {
|
||||
t.Fatal("more than 30 members reached MCP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiMemberListContracts(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_member": {`{"success":true,"members":[{"userId":"u1","nick":"A","roleId":"READER","type":"USER","outer":false}],"truncated":false}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+member-list", "--workspace", "w", "--limit", "50", "--filter-role", "READER,EDITOR")
|
||||
if err != nil || out["count"] != float64(1) || caller.calls[0].args["maxResults"] != 50 {
|
||||
t.Fatalf("member list output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
for _, value := range []string{"0", "51"} {
|
||||
caller = &wikiCoverageCaller{}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+member-list", "--workspace", "w", "--limit", value); err == nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid member limit %s reached MCP", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiReadWorkflows(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
responses map[string][]string
|
||||
wantTool string
|
||||
wantCount float64
|
||||
}{
|
||||
{name: "node list", args: []string{"+node-list", "--workspace", "w", "--folder", "f", "--limit", "2", "--cursor", "c"}, responses: map[string][]string{"doc/list_nodes": {`{"success":true,"nodes":[{"id":"n","title":"Doc","parentId":"f","spaceId":"w"}],"hasMore":false}`}}, wantTool: "list_nodes", wantCount: 1},
|
||||
{name: "node search", args: []string{"+node-search", "--workspace", "w", "--query", "Doc", "--extensions", "adoc", "--limit", "2", "--cursor", "c"}, responses: map[string][]string{"doc/search_documents": {`{"success":true,"documents":[],"hasMore":false}`}}, wantTool: "search_documents", wantCount: 0},
|
||||
{name: "feed list", args: []string{"+feed-list", "--workspace", "w", "--limit", "2", "--cursor", "c", "--exclude-file"}, responses: map[string][]string{"wiki/list_workspace_feeds": {`{"success":true,"feeds":[{"feedId":"x","feedType":"update","fileId":"n"}],"hasMore":false}`}}, wantTool: "list_workspace_feeds", wantCount: 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: tc.responses}
|
||||
out, err := runWikiCoverageCLI(t, caller, tc.args...)
|
||||
if err != nil || out["count"] != tc.wantCount || caller.calls[0].tool != tc.wantTool {
|
||||
t.Fatalf("output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("node get", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true,"result":{"nodeId":"n","title":"Doc"}}`}}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+node-get", "--node", "n")
|
||||
if err != nil || out["nodeId"] != "n" {
|
||||
t.Fatalf("node get output=%#v err=%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiWriteWorkflows(t *testing.T) {
|
||||
t.Run("confirmation gates precede every remote call", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "delete space", args: []string{"+delete-space", "--workspace", "w"}},
|
||||
{name: "copy node", args: []string{"+node-copy", "--workspace", "w", "--node", "source"}},
|
||||
{name: "move node", args: []string{"+move", "--workspace", "target", "--node", "n"}},
|
||||
{name: "move node to drive", args: []string{"+move-to-drive", "--node", "n"}},
|
||||
{name: "delete node", args: []string{"+node-delete", "--workspace", "w", "--node", "n"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{}
|
||||
_, err := runWikiCoverageCLI(t, caller, tc.args...)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed error = %#v, want confirmation_required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed shortcut reached MCP: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("node create dry and verified", func(t *testing.T) {
|
||||
dry := &wikiCoverageCaller{}
|
||||
out, err := runWikiCoverageCLI(t, dry, "+node-create", "--workspace", "w", "--folder", "f", "--name", "Doc", "--type", "adoc", "--dry-run")
|
||||
if err != nil || out["executed"] != false || len(dry.calls) != 0 {
|
||||
t.Fatalf("create dry output=%#v err=%v calls=%#v", out, err, dry.calls)
|
||||
}
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"doc/create_file": {`{"success":true,"data":{"fileId":"n"}}`},
|
||||
"doc/get_document_info": {`{"success":true,"nodeId":"n","workspaceId":"w","folderId":"f"}`},
|
||||
}}
|
||||
out, err = runWikiCoverageCLI(t, caller, "+node-create", "--workspace", "w", "--folder", "f", "--name", "Doc")
|
||||
if err != nil || out["nodeId"] != "n" || len(caller.calls) != 2 {
|
||||
t.Fatalf("create output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("node copy dry and verified", func(t *testing.T) {
|
||||
dry := &wikiCoverageCaller{}
|
||||
out, err := runWikiCoverageCLI(t, dry, "+node-copy", "--workspace", "w", "--folder", "f", "--node", "source", "--dry-run", "--yes")
|
||||
if err != nil || out["executed"] != false || len(dry.calls) != 0 {
|
||||
t.Fatalf("copy dry output=%#v err=%v calls=%#v", out, err, dry.calls)
|
||||
}
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"doc/copy_document": {`{"success":true,"nodeId":"copy"}`},
|
||||
"doc/get_document_info": {`{"success":true,"nodeId":"copy","workspaceId":"w"}`},
|
||||
}}
|
||||
out, err = runWikiCoverageCLI(t, caller, "+node-copy", "--workspace", "w", "--node", "source", "--yes")
|
||||
if err != nil || out["nodeId"] != "copy" || len(caller.calls) != 2 {
|
||||
t.Fatalf("copy output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("move within wiki and to drive", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old","folderId":"old-f"}`, `{"nodeId":"n","workspaceId":"target","folderId":"f"}`},
|
||||
"doc/move_document": {`{"success":true}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+move", "--workspace", "target", "--folder", "f", "--node", "n", "--yes")
|
||||
if err != nil || out["nodeId"] != "n" || len(caller.calls) != 3 {
|
||||
t.Fatalf("move output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
caller = &wikiCoverageCaller{responses: map[string][]string{
|
||||
"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"drive"}`},
|
||||
"doc/move_document": {`{"success":true}`},
|
||||
}}
|
||||
out, err = runWikiCoverageCLI(t, caller, "+move-to-drive", "--node", "n", "--yes")
|
||||
if err != nil || out["nodeId"] != "n" {
|
||||
t.Fatalf("move-to-drive output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("move dry-run only preflights", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+move", "--workspace", "target", "--node", "n", "--dry-run", "--yes")
|
||||
if err != nil || out["executed"] != false || len(caller.calls) != 1 {
|
||||
t.Fatalf("move dry output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("node delete dry and terminal", func(t *testing.T) {
|
||||
dry := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}}}
|
||||
out, err := runWikiCoverageCLI(t, dry, "+node-delete", "--workspace", "w", "--node", "n", "--dry-run", "--yes")
|
||||
if err != nil || out["executed"] != false || len(dry.calls) != 1 {
|
||||
t.Fatalf("delete dry output=%#v err=%v calls=%#v", out, err, dry.calls)
|
||||
}
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`},
|
||||
"doc/delete_document": {`{"success":true}`},
|
||||
}}
|
||||
out, err = runWikiCoverageCLI(t, caller, "+node-delete", "--workspace", "w", "--node", "n", "--yes")
|
||||
if err != nil || out["deleted"] != true || len(caller.calls) != 2 {
|
||||
t.Fatalf("delete output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiResponseValidationBranches(t *testing.T) {
|
||||
if _, err := requireWikiResponse(nil, "op"); err == nil {
|
||||
t.Fatal("empty response accepted")
|
||||
}
|
||||
for _, data := range []map[string]any{{"success": "yes"}, {"success": false}, {"success": false, "message": "denied"}} {
|
||||
if _, err := requireWikiResponse(data, "op"); err == nil {
|
||||
t.Fatalf("invalid response accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if _, err := requireWikiWrite(map[string]any{"result": map[string]any{"id": "x"}}, "op"); err == nil {
|
||||
t.Fatal("write without terminal success accepted")
|
||||
}
|
||||
if _, err := requireWikiWrite(map[string]any{"success": false}, "op"); err == nil {
|
||||
t.Fatal("failed write response accepted")
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{"success": false}, {"result": "bad"}, {"result": map[string]any{}}, {"only": true},
|
||||
} {
|
||||
if _, err := requireWikiObject(data, "op"); err == nil {
|
||||
t.Fatalf("invalid object accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if object, err := requireWikiObject(map[string]any{"success": true, "id": "x"}, "op"); err != nil || object["id"] != "x" {
|
||||
t.Fatalf("direct object=%#v err=%v", object, err)
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{"success": false}, {"result": "bad"}, {"result": map[string]any{"items": "bad"}}, {"items": []any{1}}, {"success": true},
|
||||
} {
|
||||
if _, _, err := requireWikiCollection(data, "op", "items"); err == nil {
|
||||
t.Fatalf("invalid collection accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if value := nestedWikiString(map[string]any{"data": map[string]any{"id": " nested "}}, "id"); value != "nested" {
|
||||
t.Fatalf("nested string=%q", value)
|
||||
}
|
||||
if value := nestedWikiString(map[string]any{"id": " direct "}, "id"); value != "direct" {
|
||||
t.Fatalf("direct string=%q", value)
|
||||
}
|
||||
if value := nestedWikiString(map[string]any{"id": 1}, "id"); value != "" {
|
||||
t.Fatalf("non-string=%q", value)
|
||||
}
|
||||
|
||||
page := map[string]any{"nextToken": "n", "hasMore": true, "truncated": false, "totalCount": 1, "autoPageComplete": true, "autoPageStopReason": "done", "pagesFetched": 2}
|
||||
out := map[string]any{}
|
||||
addWikiPagination(out, page)
|
||||
for _, key := range []string{"nextCursor", "hasMore", "truncated", "totalCount", "autoPageComplete", "autoPageStopReason", "pagesFetched"} {
|
||||
if _, ok := out[key]; !ok {
|
||||
t.Fatalf("pagination output missing %s: %#v", key, out)
|
||||
}
|
||||
}
|
||||
rows := projectWikiRows([]any{map[string]any{"legacy": "x", "empty": nil}}, map[string][]string{"value": {"empty", "legacy"}})
|
||||
if len(rows) != 1 || rows[0]["value"] != "x" {
|
||||
t.Fatalf("projected rows=%#v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiAliasAndCancellationBranches(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/remove_member": {`{"success":true}`}}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+member-remove", "--workspace", "w", "--user", "u1")
|
||||
if err != nil || out["userCount"] != float64(1) {
|
||||
t.Fatalf("visible member alias output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "probe"}
|
||||
cmd.Flags().StringSlice("users", nil, "")
|
||||
cmd.Flags().StringSlice("user", nil, "")
|
||||
rt := shortcut.RuntimeContextForTest(cmd, MemberRemove)
|
||||
if got := wikiStringSliceFirst(rt, "users", "user"); len(got) != 0 {
|
||||
t.Fatalf("unset string slice=%v", got)
|
||||
}
|
||||
|
||||
pageCmd := &cobra.Command{Use: "page"}
|
||||
pageCmd.Flags().Bool("page-all", true, "")
|
||||
pageCmd.Flags().Int("page-limit", 2, "")
|
||||
pageCmd.Flags().Int("max-items", 0, "")
|
||||
pageCmd.Flags().Int("page-delay", 1, "")
|
||||
pageCmd.Flags().String("cursor", "", "")
|
||||
pageCmd.Flags().String("page-token", "", "")
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
pageCmd.SetContext(cancelled)
|
||||
pageRT := shortcut.RuntimeContextForTest(pageCmd, SpaceList)
|
||||
_, _, err = collectWikiPages(pageRT, "probe", 1, []string{"items"}, func(string, int) (map[string]any, error) {
|
||||
return map[string]any{"items": []any{}, "hasMore": true, "nextCursor": "next"}, nil
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled page delay err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiPaginationFailureModes(t *testing.T) {
|
||||
t.Run("two pages", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_wikiSpaces": {
|
||||
`{"wikiSpaces":[{"workspaceId":"w1"}],"hasMore":true,"nextCursor":"next"}`,
|
||||
`{"wikiSpaces":[{"workspaceId":"w2"}],"hasMore":false}`,
|
||||
},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "1", "--page-all", "--page-limit", "2")
|
||||
if err != nil || out["count"] != float64(2) || out["autoPageComplete"] != true {
|
||||
t.Fatalf("pagination output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("max items trims first page", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_wikiSpaces": {`{"wikiSpaces":[{"workspaceId":"w1"},{"workspaceId":"w2"}],"hasMore":true,"nextCursor":"next"}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "2", "--page-all", "--max-items", "1")
|
||||
spaces, ok := out["spaces"].([]any)
|
||||
if err != nil || !ok || out["count"] != float64(1) || len(spaces) != 1 || out["autoPageStopReason"] != "max_items" {
|
||||
t.Fatalf("max-items output=%#v err=%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("max items trims remaining page", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_wikiSpaces": {
|
||||
`{"wikiSpaces":[{"workspaceId":"w1"},{"workspaceId":"w2"}],"hasMore":true,"nextCursor":"next"}`,
|
||||
`{"wikiSpaces":[{"workspaceId":"w3"},{"workspaceId":"w4"}],"hasMore":false}`,
|
||||
},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "2", "--page-all", "--page-limit", "2", "--max-items", "3")
|
||||
spaces, ok := out["spaces"].([]any)
|
||||
if err != nil || !ok || out["count"] != float64(3) || len(spaces) != 3 || out["autoPageStopReason"] != "max_items" {
|
||||
t.Fatalf("max-items output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[1].args["pageSize"] != 1 {
|
||||
t.Fatalf("remaining page request=%#v, want pageSize=1", caller.calls)
|
||||
}
|
||||
third, ok := spaces[2].(map[string]any)
|
||||
if !ok || third["workspaceId"] != "w3" {
|
||||
t.Fatalf("trimmed spaces=%#v, want w3 as final item", spaces)
|
||||
}
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
response string
|
||||
args []string
|
||||
}{
|
||||
{name: "missing has more", response: `{"wikiSpaces":[]}`, args: []string{"--page-all"}},
|
||||
{name: "malformed has more", response: `{"wikiSpaces":[],"hasMore":"yes"}`, args: []string{"--page-all"}},
|
||||
{name: "missing cursor", response: `{"wikiSpaces":[],"hasMore":true}`, args: []string{"--page-all"}},
|
||||
{name: "stalled cursor", response: `{"wikiSpaces":[],"hasMore":true,"nextCursor":"same"}`, args: []string{"--cursor", "same", "--page-all"}},
|
||||
{name: "page limit", response: `{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`, args: []string{"--page-all", "--page-limit", "1"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/list_wikiSpaces": {tc.response}}}
|
||||
if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-list"}, tc.args...)...); err == nil {
|
||||
t.Fatalf("pagination failure %s succeeded", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, args := range [][]string{{"--page-limit", "0", "--page-all"}, {"--max-items", "1"}, {"--page-delay", "1"}} {
|
||||
caller := &wikiCoverageCaller{}
|
||||
if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-list"}, args...)...); err == nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid auto-page controls succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiMCPFailuresPropagate(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{
|
||||
responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[]}`}},
|
||||
errors: map[string][]error{"wiki/list_wikiSpaces": {errors.New("read failed")}},
|
||||
}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+space-list"); err == nil {
|
||||
t.Fatal("MCP failure was swallowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiSpaceFailureBranches(t *testing.T) {
|
||||
assertErr := func(name string, caller *wikiCoverageCaller, args ...string) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := runWikiCoverageCLI(t, caller, args...); err == nil {
|
||||
t.Fatalf("%s unexpectedly succeeded; calls=%#v", name, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
backend := errors.New("backend failed")
|
||||
assertErr("search invalid limit", &wikiCoverageCaller{}, "+space-search", "--query", "x", "--limit", "bad")
|
||||
assertErr("search call", &wikiCoverageCaller{errors: map[string][]error{"wiki/search_wikiSpaces": {backend}}}, "+space-search", "--query", "x")
|
||||
assertErr("search collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/search_wikiSpaces": {`{"success":true}`}}}, "+space-search", "--query", "x")
|
||||
assertErr("get call", &wikiCoverageCaller{errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+space-get", "--workspace", "w")
|
||||
assertErr("get object", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true}`}}}, "+space-get", "--workspace", "w")
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w","name":"Docs"}`}}}
|
||||
if out, err := runWikiCoverageCLI(t, caller, "+space-get", "--workspace", "w"); err != nil || out["workspaceId"] != "w" {
|
||||
t.Fatalf("space get output=%#v err=%v", out, err)
|
||||
}
|
||||
|
||||
assertErr("create write call", &wikiCoverageCaller{errors: map[string][]error{"wiki/create_wikiSpace": {backend}}}, "+space-create", "--name", "Docs")
|
||||
assertErr("create terminal", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"result":{"workspaceId":"w"}}`}}}, "+space-create", "--name", "Docs")
|
||||
assertErr("create id", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true}`}}}, "+space-create", "--name", "Docs")
|
||||
assertErr("create readback call", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}}, errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+space-create", "--name", "Docs")
|
||||
assertErr("create readback object", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}, "wiki/get_wikiSpace": {`{"success":true}`}}}, "+space-create", "--name", "Docs")
|
||||
assertErr("create readback mismatch", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}, "wiki/get_wikiSpace": {`{"workspaceId":"other"}`}}}, "+space-create", "--name", "Docs")
|
||||
spaceMismatch := &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}, "wiki/get_wikiSpace": {`{"success":true,"data":{"workspaceId":"other"}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, spaceMismatch, "+space-create", "--name", "Docs"); err == nil || !strings.Contains(err.Error(), "不一致") {
|
||||
t.Fatalf("space create mismatch error=%v calls=%#v", err, spaceMismatch.calls)
|
||||
}
|
||||
|
||||
assertErr("delete preflight call", &wikiCoverageCaller{errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+delete-space", "--workspace", "w", "--yes")
|
||||
assertErr("delete preflight object", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true}`}}}, "+delete-space", "--workspace", "w", "--yes")
|
||||
assertErr("delete write call", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w"}`}}, errors: map[string][]error{"wiki/delete_wikiSpace": {backend}}}, "+delete-space", "--workspace", "w", "--yes")
|
||||
assertErr("delete terminal", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w"}`}, "wiki/delete_wikiSpace": {`{"result":{}}`}}}, "+delete-space", "--workspace", "w", "--yes")
|
||||
delWrite := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}}, errors: map[string][]error{"wiki/delete_wikiSpace": {backend}}}
|
||||
if _, err := runWikiCoverageCLI(t, delWrite, "+delete-space", "--workspace", "w", "--yes"); err == nil || !strings.Contains(err.Error(), "backend failed") {
|
||||
t.Fatalf("delete write error=%v calls=%#v", err, delWrite.calls)
|
||||
}
|
||||
delTerminal := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}, "wiki/delete_wikiSpace": {`{"result":{}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, delTerminal, "+delete-space", "--workspace", "w", "--yes"); err == nil || !strings.Contains(err.Error(), "success=true") {
|
||||
t.Fatalf("delete terminal error=%v calls=%#v", err, delTerminal.calls)
|
||||
}
|
||||
|
||||
assertErr("member list call", &wikiCoverageCaller{errors: map[string][]error{"wiki/list_member": {backend}}}, "+member-list", "--workspace", "w")
|
||||
assertErr("member list collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/list_member": {`{"success":true}`}}}, "+member-list", "--workspace", "w")
|
||||
assertErr("member write call", &wikiCoverageCaller{errors: map[string][]error{"wiki/add_member": {backend}}}, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER")
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiNodeFailureBranches(t *testing.T) {
|
||||
assertErr := func(name string, caller *wikiCoverageCaller, args ...string) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := runWikiCoverageCLI(t, caller, args...); err == nil {
|
||||
t.Fatalf("%s unexpectedly succeeded; calls=%#v", name, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
backend := errors.New("backend failed")
|
||||
assertErr("list call", &wikiCoverageCaller{errors: map[string][]error{"doc/list_nodes": {backend}}}, "+node-list", "--workspace", "w")
|
||||
assertErr("list collection", &wikiCoverageCaller{responses: map[string][]string{"doc/list_nodes": {`{"success":true}`}}}, "+node-list", "--workspace", "w")
|
||||
assertErr("get call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, "+node-get", "--node", "n")
|
||||
assertErr("get object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, "+node-get", "--node", "n")
|
||||
assertErr("get id", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"name":"Doc"}`}}}, "+node-get", "--node", "n")
|
||||
missingID := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true,"result":{"name":"Doc"}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, missingID, "+node-get", "--node", "n"); err == nil || !strings.Contains(err.Error(), "nodeId") {
|
||||
t.Fatalf("missing node id error=%v calls=%#v", err, missingID.calls)
|
||||
}
|
||||
assertErr("search call", &wikiCoverageCaller{errors: map[string][]error{"doc/search_documents": {backend}}}, "+node-search", "--workspace", "w", "--query", "x")
|
||||
assertErr("search collection", &wikiCoverageCaller{responses: map[string][]string{"doc/search_documents": {`{"success":true}`}}}, "+node-search", "--workspace", "w", "--query", "x")
|
||||
|
||||
createArgs := []string{"+node-create", "--workspace", "w", "--name", "Doc"}
|
||||
assertErr("create write", &wikiCoverageCaller{errors: map[string][]error{"doc/create_file": {backend}}}, createArgs...)
|
||||
assertErr("create terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"result":{"fileId":"n"}}`}}}, createArgs...)
|
||||
assertErr("create id", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true}`}}}, createArgs...)
|
||||
assertErr("create readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}}, errors: map[string][]error{"doc/get_document_info": {backend}}}, createArgs...)
|
||||
assertErr("create readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"success":true}`}}}, createArgs...)
|
||||
assertErr("create mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"nodeId":"other"}`}}}, createArgs...)
|
||||
createMismatch := &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"result":{"fileId":"n"}}`}, "doc/get_document_info": {`{"success":true,"result":{"nodeId":"other"}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, createMismatch, createArgs...); err == nil || !strings.Contains(err.Error(), "不一致") {
|
||||
t.Fatalf("create mismatch error=%v calls=%#v", err, createMismatch.calls)
|
||||
}
|
||||
|
||||
copyArgs := []string{"+node-copy", "--workspace", "w", "--node", "source", "--yes"}
|
||||
assertErr("copy write", &wikiCoverageCaller{errors: map[string][]error{"doc/copy_document": {backend}}}, copyArgs...)
|
||||
assertErr("copy terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"result":{"fileId":"n"}}`}}}, copyArgs...)
|
||||
assertErr("copy id", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true}`}}}, copyArgs...)
|
||||
assertErr("copy readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"fileId":"n"}`}}, errors: map[string][]error{"doc/get_document_info": {backend}}}, copyArgs...)
|
||||
assertErr("copy readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"success":true}`}}}, copyArgs...)
|
||||
copyMismatch := &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"result":{"fileId":"n"}}`}, "doc/get_document_info": {`{"success":true,"result":{"nodeId":"other"}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, copyMismatch, copyArgs...); err == nil || !strings.Contains(err.Error(), "不一致") {
|
||||
t.Fatalf("copy mismatch error=%v calls=%#v", err, copyMismatch.calls)
|
||||
}
|
||||
|
||||
moveArgs := []string{"+move", "--workspace", "target", "--node", "n", "--yes"}
|
||||
assertErr("move preflight call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, moveArgs...)
|
||||
assertErr("move preflight object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, moveArgs...)
|
||||
assertErr("move write", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}}, errors: map[string][]error{"doc/move_document": {backend}}}, moveArgs...)
|
||||
assertErr("move terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}, "doc/move_document": {`{"result":{}}`}}}, moveArgs...)
|
||||
assertErr("move readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}, "doc/move_document": {`{"success":true}`}}, errors: map[string][]error{"doc/get_document_info": {nil, backend}}}, moveArgs...)
|
||||
assertErr("move readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"success":true}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...)
|
||||
assertErr("move id mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"other","workspaceId":"target"}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...)
|
||||
assertErr("move workspace mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"other"}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...)
|
||||
assertErr("move folder mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"target","folderId":"other"}`}, "doc/move_document": {`{"success":true}`}}}, append(moveArgs, "--folder", "f")...)
|
||||
assertErr("drive move unchanged", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"same"}`, `{"nodeId":"n","workspaceId":"same"}`}, "doc/move_document": {`{"success":true}`}}}, "+move-to-drive", "--node", "n", "--yes")
|
||||
|
||||
deleteArgs := []string{"+node-delete", "--workspace", "w", "--node", "n", "--yes"}
|
||||
assertErr("delete preflight call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, deleteArgs...)
|
||||
assertErr("delete preflight object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, deleteArgs...)
|
||||
assertErr("delete workspace", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"other"}`}}}, deleteArgs...)
|
||||
assertErr("delete write", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}}, errors: map[string][]error{"doc/delete_document": {backend}}}, deleteArgs...)
|
||||
assertErr("delete terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}, "doc/delete_document": {`{"result":{}}`}}}, deleteArgs...)
|
||||
assertErr("feed collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/list_workspace_feeds": {`{"success":true}`}}}, "+feed-list", "--workspace", "w")
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiSecondPageFailures(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{
|
||||
responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`}},
|
||||
errors: map[string][]error{"wiki/list_wikiSpaces": {nil, errors.New("second page failed")}},
|
||||
}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--page-all"); err == nil {
|
||||
t.Fatal("second-page transport error was swallowed")
|
||||
}
|
||||
caller = &wikiCoverageCaller{responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`, `{"success":true}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--page-all"); err == nil {
|
||||
t.Fatal("second-page malformed collection was swallowed")
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,32 @@ set -eu
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
|
||||
usage() {
|
||||
printf '%s\n' "usage: $0 <changed|list> BASE_REF HEAD_REF" >&2
|
||||
printf '%s\n' \
|
||||
"usage: $0 <changed|list> BASE_REF HEAD_REF" \
|
||||
" $0 list-shard SHARD BASE_REF HEAD_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
[ "${1:-}" = changed ] || [ "${1:-}" = list ] || usage
|
||||
[ "$#" -eq 3 ] || usage
|
||||
|
||||
MODE="$1"
|
||||
BASE_REF="$2"
|
||||
HEAD_REF="$3"
|
||||
# list-shard narrows the impacted set to one test shard so focused CI runs can
|
||||
# fan the same package plan across the shard matrix instead of testing every
|
||||
# impacted package in a single long-lived job.
|
||||
SHARD=""
|
||||
case "${1:-}" in
|
||||
changed|list)
|
||||
[ "$#" -eq 3 ] || usage
|
||||
MODE="$1"
|
||||
BASE_REF="$2"
|
||||
HEAD_REF="$3"
|
||||
;;
|
||||
list-shard)
|
||||
[ "$#" -eq 4 ] || usage
|
||||
MODE="$1"
|
||||
SHARD="$2"
|
||||
BASE_REF="$3"
|
||||
HEAD_REF="$4"
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
cd "$ROOT"
|
||||
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
|
||||
@@ -116,9 +132,29 @@ while IFS= read -r file; do
|
||||
[ "$embedded_owner_found" = true ] || continue
|
||||
done < "$files"
|
||||
|
||||
# emit_selection prints the planned packages, restricted to SHARD when the
|
||||
# caller asked for one. Restriction reuses scripts/ci/test-packages.sh as the
|
||||
# single source of shard membership, which also means an unknown shard name
|
||||
# aborts there instead of being reported as an empty selection: a silently empty
|
||||
# shard would let a mistyped CI shard name skip every test and still succeed.
|
||||
emit_selection() {
|
||||
selection="$1"
|
||||
if [ -z "$SHARD" ]; then
|
||||
cat "$selection"
|
||||
return 0
|
||||
fi
|
||||
shard_packages="$workdir/shard-packages"
|
||||
"$ROOT/scripts/ci/test-packages.sh" list "$SHARD" > "$shard_packages"
|
||||
LC_ALL=C sort -u "$shard_packages" -o "$shard_packages"
|
||||
LC_ALL=C sort -u "$selection" -o "$selection"
|
||||
# An empty intersection is a normal outcome for a shard no change touched, so
|
||||
# comm's silence must not be treated as an error.
|
||||
LC_ALL=C comm -12 "$selection" "$shard_packages"
|
||||
}
|
||||
|
||||
LC_ALL=C sort -u "$changed_packages" -o "$changed_packages"
|
||||
if [ "$MODE" = changed ] || [ ! -s "$changed_packages" ]; then
|
||||
cat "$changed_packages"
|
||||
emit_selection "$changed_packages"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -133,4 +169,5 @@ while IFS= read -r package; do
|
||||
done < "$all_packages" > "$impacted_packages"
|
||||
|
||||
cat "$changed_packages" >> "$impacted_packages"
|
||||
LC_ALL=C sort -u "$impacted_packages"
|
||||
LC_ALL=C sort -u "$impacted_packages" -o "$impacted_packages"
|
||||
emit_selection "$impacted_packages"
|
||||
|
||||
Executable
+216
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run interactive, real-data Wiki Shortcut verification on a disposable space.
|
||||
|
||||
Requires an authenticated `dws` session. Set DWS_WIKI_E2E_MEMBER_ID to a real
|
||||
internal user ID that may be granted temporary access to the empty fixture.
|
||||
The script prints capability labels only; business IDs, names, URLs, and raw
|
||||
responses remain in memory. Commands that require confirmation use the normal
|
||||
`dws` terminal prompt, including the disposable-space cleanup in `finally`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DWS = ROOT / "dws"
|
||||
MEMBER_ID = os.environ.get("DWS_WIKI_E2E_MEMBER_ID", "").strip()
|
||||
|
||||
|
||||
class E2EFailure(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def invoke(args: list[str], *, require_confirmation: bool = False) -> dict:
|
||||
process = subprocess.run(
|
||||
[str(DWS), "wiki", *args, "--format", "json"],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
# Confirmation prompts are written to stderr. Keep that stream on the
|
||||
# terminal for guarded operations so dws itself obtains the user's
|
||||
# answer; ordinary calls stay quiet and redact raw backend errors.
|
||||
stderr=None if require_confirmation else subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
try:
|
||||
envelope = json.loads(process.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise E2EFailure(f"non-JSON response (exit {process.returncode})") from exc
|
||||
if process.returncode != 0 or envelope.get("ok") is not True:
|
||||
error = envelope.get("error") or {}
|
||||
reason = error.get("reason") or error.get("category") or "command_failed"
|
||||
raise E2EFailure(f"{reason} (exit {process.returncode})")
|
||||
data = envelope.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise E2EFailure("success envelope lacks object data")
|
||||
return data
|
||||
|
||||
|
||||
def check(label: str, condition: bool) -> None:
|
||||
if not condition:
|
||||
raise E2EFailure(f"{label}: business assertion failed")
|
||||
print(f"PASS {label}")
|
||||
|
||||
|
||||
def member_role(data: dict, user_id: str) -> str:
|
||||
for member in data.get("members", []):
|
||||
if member.get("id") == user_id:
|
||||
return str(member.get("role") or "").upper()
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not sys.stdin.isatty() or not sys.stderr.isatty():
|
||||
raise E2EFailure(
|
||||
"run in an interactive terminal; guarded operations require an explicit dws confirmation"
|
||||
)
|
||||
if not DWS.exists():
|
||||
raise E2EFailure("build ./dws first with make build")
|
||||
if not MEMBER_ID:
|
||||
raise E2EFailure("set DWS_WIKI_E2E_MEMBER_ID to a temporary internal test member")
|
||||
|
||||
stamp = time.strftime("%m%d%H%M%S")
|
||||
space_name = f"DWS Wiki E2E {stamp}" # <= 32 characters
|
||||
workspace = ""
|
||||
disposable_nodes: list[str] = []
|
||||
member_added = False
|
||||
try:
|
||||
created = invoke(["+space-create", "--name", space_name, "--desc", "Disposable E2E fixture"])
|
||||
workspace = str(created.get("workspaceId") or "")
|
||||
check("space-create-readback", bool(workspace) and created.get("space", {}).get("workspaceId") == workspace)
|
||||
|
||||
page = invoke(["+space-list", "--limit", "1"])
|
||||
check("space-list-cursor", page.get("count") == 1 and isinstance(page.get("hasMore"), bool))
|
||||
all_spaces = invoke(["+space-list", "--limit", "1", "--page-all", "--max-items", "2"])
|
||||
check("space-list-auto-page", all_spaces.get("count") == 2 and len(all_spaces.get("spaces", [])) == 2)
|
||||
search_ok = False
|
||||
for _ in range(8):
|
||||
searched = invoke(["+space-search", "--query", space_name])
|
||||
if any(row.get("workspaceId") == workspace for row in searched.get("spaces", [])):
|
||||
search_ok = True
|
||||
break
|
||||
time.sleep(2)
|
||||
check("space-search", search_ok)
|
||||
detail = invoke(["+space-get", "--workspace", workspace])
|
||||
check("space-get", detail.get("workspaceId") == workspace)
|
||||
resolved = invoke(["+resolve-space", "--name", space_name])
|
||||
check("resolve-space", resolved.get("resolved") is True and resolved.get("spaceId") == workspace)
|
||||
|
||||
empty_nodes = invoke(["+node-list", "--workspace", workspace])
|
||||
check("node-list-explicit-empty", empty_nodes.get("count") == 0 and empty_nodes.get("nodes") == [])
|
||||
|
||||
folder = invoke(["+node-create", "--workspace", workspace, "--name", "E2E Folder", "--type", "folder"])
|
||||
folder_id = str(folder.get("nodeId") or "")
|
||||
disposable_nodes.append(folder_id)
|
||||
check("node-create-folder-readback", bool(folder_id) and folder.get("node", {}).get("nodeId") == folder_id)
|
||||
document = invoke(["+node-create", "--workspace", workspace, "--folder", folder_id, "--name", "E2E Document"])
|
||||
node_id = str(document.get("nodeId") or "")
|
||||
disposable_nodes.append(node_id)
|
||||
check("node-create-document-readback", bool(node_id) and document.get("node", {}).get("nodeId") == node_id)
|
||||
|
||||
listed = invoke(["+node-list", "--workspace", workspace, "--limit", "1"])
|
||||
check("node-list-cursor", listed.get("count") == 1 and listed.get("hasMore") is True and bool(listed.get("nextCursor")))
|
||||
all_nodes = invoke(["+node-list", "--workspace", workspace, "--limit", "1", "--page-all"])
|
||||
check(
|
||||
"node-list-auto-page",
|
||||
all_nodes.get("count", 0) >= 1
|
||||
and (all_nodes.get("autoPageComplete") is True or all_nodes.get("hasMore") is False),
|
||||
)
|
||||
info = invoke(["+node-get", "--node", node_id])
|
||||
check("node-get", info.get("nodeId") == node_id)
|
||||
|
||||
# Search indexing can lag after a create; retry without accepting a
|
||||
# malformed/missing collection as an empty success.
|
||||
search_ok = False
|
||||
for _ in range(6):
|
||||
found = invoke(["+node-search", "--workspace", workspace, "--query", "E2E Document"])
|
||||
if any(row.get("nodeId") == node_id for row in found.get("nodes", [])):
|
||||
search_ok = True
|
||||
break
|
||||
time.sleep(2)
|
||||
check("node-search", search_ok)
|
||||
|
||||
copied = invoke(
|
||||
["+node-copy", "--workspace", workspace, "--folder", folder_id, "--node", node_id],
|
||||
require_confirmation=True,
|
||||
)
|
||||
copy_id = str(copied.get("nodeId") or "")
|
||||
disposable_nodes.append(copy_id)
|
||||
check("node-copy-readback", bool(copy_id) and bool(copied.get("copy")))
|
||||
|
||||
moved = invoke(
|
||||
["+move", "--workspace", workspace, "--folder", folder_id, "--node", node_id],
|
||||
require_confirmation=True,
|
||||
)
|
||||
check("move-readback", moved.get("node", {}).get("workspaceId") == workspace and moved.get("node", {}).get("folderId") == folder_id)
|
||||
moved_out = invoke(["+move-to-drive", "--node", node_id], require_confirmation=True)
|
||||
check("move-to-drive-readback", moved_out.get("node", {}).get("workspaceId") != workspace)
|
||||
moved_back = invoke(
|
||||
["+node-move", "--workspace", workspace, "--folder", folder_id, "--node", node_id],
|
||||
require_confirmation=True,
|
||||
)
|
||||
check("node-move-alias-readback", moved_back.get("node", {}).get("workspaceId") == workspace)
|
||||
|
||||
named = invoke(["+wiki-new-doc", "--space", space_name, "--title", "E2E Name Resolved"])
|
||||
named_id = str(named.get("nodeId") or "")
|
||||
disposable_nodes.append(named_id)
|
||||
check("wiki-new-doc-readback", bool(named_id) and named.get("document", {}).get("nodeId") == named_id)
|
||||
|
||||
members = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
|
||||
check("member-list", members.get("count", 0) >= 1 and isinstance(members.get("members"), list))
|
||||
added = invoke(["+member-add", "--workspace", workspace, "--users", MEMBER_ID, "--role", "READER"])
|
||||
member_added = True
|
||||
check("member-add-terminal", added.get("success") is True and added.get("verifiedBy") == "write_terminal_success")
|
||||
after_add = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
|
||||
check("member-add-fixture-readback", after_add.get("truncated") is not True and member_role(after_add, MEMBER_ID) == "READER")
|
||||
updated = invoke(["+member-update", "--workspace", workspace, "--users", MEMBER_ID, "--role", "EDITOR"])
|
||||
check("member-update-terminal", updated.get("success") is True and updated.get("verifiedBy") == "write_terminal_success")
|
||||
after_update = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
|
||||
check("member-update-fixture-readback", after_update.get("truncated") is not True and member_role(after_update, MEMBER_ID) == "EDITOR")
|
||||
removed = invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID])
|
||||
member_added = False
|
||||
check("member-remove-terminal", removed.get("success") is True and removed.get("verifiedBy") == "write_terminal_success")
|
||||
after_remove = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
|
||||
check("member-remove-fixture-readback", after_remove.get("truncated") is not True and member_role(after_remove, MEMBER_ID) == "")
|
||||
|
||||
feeds = invoke(["+feed-list", "--workspace", workspace, "--limit", "10"])
|
||||
check("feed-list", isinstance(feeds.get("feeds"), list))
|
||||
|
||||
# Exercise high-risk delete before final whole-space cleanup.
|
||||
delete_target = disposable_nodes.pop()
|
||||
deleted = invoke(
|
||||
["+node-delete", "--workspace", workspace, "--node", delete_target],
|
||||
require_confirmation=True,
|
||||
)
|
||||
check("node-delete-terminal", deleted.get("success") is True and deleted.get("deleted") is True)
|
||||
return 0
|
||||
finally:
|
||||
if workspace:
|
||||
if member_added:
|
||||
try:
|
||||
invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
deleted = invoke(
|
||||
["+space-delete", "--workspace", workspace],
|
||||
require_confirmation=True,
|
||||
)
|
||||
check("space-delete-alias-cleanup", deleted.get("success") is True and deleted.get("deleted") is True)
|
||||
except Exception as exc:
|
||||
print(f"CLEANUP FAILED: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except E2EFailure as exc:
|
||||
print(f"FAIL {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -24,6 +24,7 @@ SEMANTIC_PATHS = [
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_aitable.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_minutes.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_drive.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_wiki.json",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ cli_version: ">=1.0.15"
|
||||
| `report` | 2 | `dingtalk-misc` |
|
||||
| `sheet` | 2 | `dingtalk-misc` |
|
||||
| `todo` | 11 | `dingtalk-todo` |
|
||||
| `wiki` | 1 | `dingtalk-wiki` |
|
||||
| `wiki` | 20 | `dingtalk-wiki` |
|
||||
<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->
|
||||
|
||||
## 多组织 / 多账号
|
||||
|
||||
@@ -24,7 +24,8 @@ metadata:
|
||||
|
||||
| Shortcut | 风险 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `dws wiki +space-search` | read | 搜索知识库 |
|
||||
| `dws wiki +resolve-space` | read | 按名称搜索知识空间并解析出唯一 spaceId(只读) |
|
||||
| `dws wiki +wiki-new-doc` | write | 在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId) |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## 意图表
|
||||
|
||||
@@ -129,6 +129,245 @@ func Value() string {
|
||||
}
|
||||
}
|
||||
|
||||
// shardChangedPackagesShards mirrors the shard names scripts/ci/test-packages.sh
|
||||
// asserts complete coverage for in its verify mode. Keeping the list here in
|
||||
// full is deliberate: the union assertion below fails if a shard is added
|
||||
// upstream without being selected, and an unknown-shard error fails if one is
|
||||
// removed, so shard-plan drift cannot silently shrink focused-test coverage.
|
||||
var shardChangedPackagesShards = []string{
|
||||
"app",
|
||||
"generators",
|
||||
"helpers",
|
||||
"cli",
|
||||
"smoke",
|
||||
"remaining",
|
||||
"release-scripts",
|
||||
}
|
||||
|
||||
func TestChangedTestPackagesShardSelectionCoversEveryImpactedPackageExactlyOnce(t *testing.T) {
|
||||
repository := newShardChangedPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial shard fixture")
|
||||
|
||||
writeChangedTestPackagesFixture(t, repository, "core/core.go", `package core
|
||||
|
||||
func Value() string {
|
||||
return "changed"
|
||||
}
|
||||
`)
|
||||
headRef := commitChangedTestPackagesFixture(t, repository, "change core")
|
||||
|
||||
impacted := runChangedTestPackages(t, repository, "list", baseRef, headRef)
|
||||
if len(impacted) == 0 {
|
||||
t.Fatal("shard fixture produced no impacted packages")
|
||||
}
|
||||
|
||||
owner := map[string]string{}
|
||||
union := make([]string, 0, len(impacted))
|
||||
for _, shard := range shardChangedPackagesShards {
|
||||
for _, pkg := range runChangedTestPackagesShard(t, repository, shard, baseRef, headRef) {
|
||||
if previous, ok := owner[pkg]; ok {
|
||||
t.Fatalf("package %q selected by both shard %q and %q", pkg, previous, shard)
|
||||
}
|
||||
owner[pkg] = shard
|
||||
union = append(union, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(union)
|
||||
if !slices.Equal(union, impacted) {
|
||||
t.Fatalf("shard selection union = %q, want every impacted package %q", union, impacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedTestPackagesShardSelectionFailsClosedOnUnknownShard(t *testing.T) {
|
||||
repository := newShardChangedPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial shard fixture")
|
||||
|
||||
writeChangedTestPackagesFixture(t, repository, "core/core.go", `package core
|
||||
|
||||
func Value() string {
|
||||
return "changed"
|
||||
}
|
||||
`)
|
||||
headRef := commitChangedTestPackagesFixture(t, repository, "change core")
|
||||
|
||||
script := filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh")
|
||||
command := exec.Command(script, "list-shard", "nonexistent", baseRef, headRef)
|
||||
command.Dir = repository
|
||||
output, err := command.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("unknown shard unexpectedly succeeded:\n%s", output)
|
||||
}
|
||||
// An unknown shard must abort rather than report an empty selection: a
|
||||
// silently empty shard would let a mistyped CI shard name skip every test
|
||||
// while still reporting success.
|
||||
if !strings.Contains(string(output), "unknown test package shard") {
|
||||
t.Fatalf("unknown shard failure = %q, want fail-closed diagnostic", output)
|
||||
}
|
||||
if strings.TrimSpace(string(output)) == "" {
|
||||
t.Fatal("unknown shard produced empty output instead of a diagnostic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedTestPackagesShardSelectionIsEmptyForUnaffectedShard(t *testing.T) {
|
||||
repository := newShardChangedPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial shard fixture")
|
||||
|
||||
writeChangedTestPackagesFixture(t, repository, "internal/helpers/helpers.go", `package helpers
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " helpers changed"
|
||||
}
|
||||
`)
|
||||
headRef := commitChangedTestPackagesFixture(t, repository, "change helpers only")
|
||||
|
||||
if selected := runChangedTestPackagesShard(t, repository, "helpers", baseRef, headRef); len(selected) == 0 {
|
||||
t.Fatal("helpers shard selected nothing for a helpers-only change")
|
||||
}
|
||||
// An unaffected shard is a normal outcome and must exit zero with no
|
||||
// output so the CI step can no-op instead of failing the job.
|
||||
if selected := runChangedTestPackagesShard(t, repository, "app", baseRef, headRef); len(selected) != 0 {
|
||||
t.Fatalf("app shard = %q, want empty for a helpers-only change", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedTestPackagesShardSelectionRejectsMissingShardArgument(t *testing.T) {
|
||||
repository := newShardChangedPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial shard fixture")
|
||||
|
||||
script := filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh")
|
||||
command := exec.Command(script, "list-shard", baseRef, baseRef)
|
||||
command.Dir = repository
|
||||
output, err := command.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("list-shard without a shard argument unexpectedly succeeded:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(string(output), "usage:") {
|
||||
t.Fatalf("missing shard argument failure = %q, want usage diagnostic", output)
|
||||
}
|
||||
}
|
||||
|
||||
func runChangedTestPackagesShard(t *testing.T, repository, shard, baseRef, headRef string) []string {
|
||||
t.Helper()
|
||||
|
||||
output := runChangedTestPackagesCommand(
|
||||
t,
|
||||
repository,
|
||||
filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh"),
|
||||
"list-shard",
|
||||
shard,
|
||||
baseRef,
|
||||
headRef,
|
||||
)
|
||||
return strings.Fields(output)
|
||||
}
|
||||
|
||||
// newShardChangedPackagesRepository builds a fixture whose directory layout
|
||||
// matches every shard scripts/ci/test-packages.sh knows about, so shard
|
||||
// selection can be exercised without depending on the real repository's working
|
||||
// tree state. Every package imports core, letting a single core edit mark the
|
||||
// whole module impacted.
|
||||
func newShardChangedPackagesRepository(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
repository := t.TempDir()
|
||||
for path, contents := range map[string]string{
|
||||
"go.mod": `module example.com/shardfixture
|
||||
|
||||
go 1.22
|
||||
`,
|
||||
"core/core.go": `package core
|
||||
|
||||
func Value() string {
|
||||
return "core"
|
||||
}
|
||||
`,
|
||||
"internal/app/app.go": `package app
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " app"
|
||||
}
|
||||
`,
|
||||
"internal/generator/gen/gen.go": `package gen
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " gen"
|
||||
}
|
||||
`,
|
||||
"internal/helpers/helpers.go": `package helpers
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " helpers"
|
||||
}
|
||||
`,
|
||||
"internal/cli/cli.go": `package cli
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " cli"
|
||||
}
|
||||
`,
|
||||
"test/smoke/smoke.go": `package smoke
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " smoke"
|
||||
}
|
||||
`,
|
||||
"test/scripts/scripts.go": `package scripts
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " scripts"
|
||||
}
|
||||
`,
|
||||
"pkg/extra/extra.go": `package extra
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " extra"
|
||||
}
|
||||
`,
|
||||
} {
|
||||
writeChangedTestPackagesFixture(t, repository, path, contents)
|
||||
}
|
||||
|
||||
projectRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve project root: %v", err)
|
||||
}
|
||||
for _, name := range []string{"changed-test-packages.sh", "test-packages.sh"} {
|
||||
script, err := os.ReadFile(filepath.Join(projectRoot, "scripts", "ci", name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
scriptPath := filepath.Join(repository, "scripts", "ci", name)
|
||||
if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil {
|
||||
t.Fatalf("create script directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(scriptPath, script, 0o755); err != nil {
|
||||
t.Fatalf("copy %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
runChangedTestPackagesCommand(t, repository, "git", "init", "-q", "-b", "main")
|
||||
runChangedTestPackagesCommand(t, repository, "git", "config", "user.name", "DWS CI")
|
||||
runChangedTestPackagesCommand(t, repository, "git", "config", "user.email", "dws-ci@example.invalid")
|
||||
return repository
|
||||
}
|
||||
|
||||
func newChangedTestPackagesRepository(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -1013,6 +1013,12 @@ if (!isHighRisk("internal/helpers/minutes.go")) {
|
||||
if (isHighRisk("internal/helpersx/minutes.go")) {
|
||||
throw new Error("helper high-risk classification must respect the path boundary");
|
||||
}
|
||||
if (!isHighRisk("internal/shortcut/wiki/wiki.go")) {
|
||||
throw new Error("shortcut changes must use the sharded full suite");
|
||||
}
|
||||
if (isHighRisk("internal/shortcuts/wiki/wiki.go")) {
|
||||
throw new Error("shortcut high-risk classification must respect the path boundary");
|
||||
}
|
||||
for (const filename of [
|
||||
"internal/cli/param_concepts.json",
|
||||
"internal/cli/param_concepts.schema.json",
|
||||
@@ -1137,6 +1143,7 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
"filename.startsWith('scripts/')",
|
||||
"filename.startsWith('verify/')",
|
||||
"filename.startsWith('internal/helpers/')",
|
||||
"filename.startsWith('internal/shortcut/')",
|
||||
"filename === 'test/fixtures/cli-interface-baseline.txt'",
|
||||
"filename.startsWith('internal/interfacesnapshot/')",
|
||||
"filename.startsWith('internal/cobracmd/')",
|
||||
@@ -1144,13 +1151,13 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
"filename.startsWith('pkg/cmdutil/')",
|
||||
"filename === 'skills_embed.go'",
|
||||
"filename.startsWith('test/mock_mcp/')",
|
||||
"name: Test (changed packages)",
|
||||
`name: "Test (focused: ${{ matrix.shard }})"`,
|
||||
"changed-test-packages.sh",
|
||||
"Verify authoritative synthetic merge",
|
||||
`test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA"`,
|
||||
`test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA"`,
|
||||
`echo "TEST_HEAD_REF=$(git rev-parse HEAD)"`,
|
||||
"list \"$TEST_BASE_REF\" \"$TEST_HEAD_REF\"",
|
||||
`list-shard "$TEST_SHARD" "$TEST_BASE_REF" "$TEST_HEAD_REF"`,
|
||||
"needs.lint.outputs.full_suite != 'true'",
|
||||
`name: "Test (race: ${{ matrix.shard }})"`,
|
||||
"name: Test (workflow and release contracts)",
|
||||
@@ -1210,8 +1217,30 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
if !strings.Contains(focusedJob, "timeout-minutes: 20") {
|
||||
t.Error("focused test job must allow the scoped race suite up to 20 minutes")
|
||||
}
|
||||
if !strings.Contains(focusedJob, `go test -v -race -count=1 -timeout=15m "${packages[@]}"`) {
|
||||
t.Error("focused race tests must retain enough package-level time for internal/app")
|
||||
// The focused path fans the impacted set across the same shards as test-race
|
||||
// and runs each shard the way test-race runs it, so no single job carries
|
||||
// internal/app together with its reverse dependencies. internal/app keeps its
|
||||
// package-level headroom through the process-isolating helper instead of one
|
||||
// long -timeout, which is strictly stronger: every process releases the
|
||||
// framework registries it populated. release-scripts is asserted because its
|
||||
// dedicated job only runs at full-suite or release-sensitive scope, so losing
|
||||
// it here would silently stop testing test/scripts changes.
|
||||
for _, want := range []string{
|
||||
`if [ "$TEST_SHARD" = "app" ]; then`,
|
||||
`test "${#packages[@]}" -eq 1`,
|
||||
`./scripts/ci/run-app-race-tests.sh run "${packages[0]}"`,
|
||||
`if [ "$TEST_SHARD" = "release-scripts" ]; then`,
|
||||
`go test -v -count=1 -timeout=10m "${packages[@]}"`,
|
||||
"timeout_budget=12m",
|
||||
`if [ "$TEST_SHARD" = "cli" ] ||`,
|
||||
`[ "$TEST_SHARD" = "smoke" ]; then`,
|
||||
"timeout_budget=15m",
|
||||
`go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"`,
|
||||
"- release-scripts",
|
||||
} {
|
||||
if !strings.Contains(focusedJob, want) {
|
||||
t.Errorf("focused test job missing shard contract %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
raceStart := focusedEnd
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWikiShortcutE2ERequiresInteractiveConfirmation(t *testing.T) {
|
||||
script := filepath.Join("..", "..", "scripts", "dev", "wiki-shortcut-e2e.py")
|
||||
source, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(source, []byte("--yes")) {
|
||||
t.Fatal("real-data Wiki E2E script must not embed confirmation bypass flags")
|
||||
}
|
||||
|
||||
python, err := exec.LookPath("python3")
|
||||
if err != nil {
|
||||
t.Skip("python3 is unavailable")
|
||||
}
|
||||
cmd := exec.Command(python, script)
|
||||
cmd.Stdin = strings.NewReader("")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err == nil {
|
||||
t.Fatal("non-interactive real-data E2E unexpectedly succeeded")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "run in an interactive terminal") {
|
||||
t.Fatalf("non-interactive failure = %q, want explicit terminal requirement", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "PASS ") {
|
||||
t.Fatalf("non-interactive E2E performed work before refusing: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
# AEM Go SDK offline snapshot
|
||||
|
||||
This directory contains the source packages required by the DWS CLI from the
|
||||
private AEM Go SDK. The root module uses a local `replace` directive so builds
|
||||
do not need access to `gitlab.alibaba-inc.com`.
|
||||
|
||||
- Upstream module: `gitlab.alibaba-inc.com/aes/aem-go-sdk`
|
||||
- Upstream version: `v0.3.0`
|
||||
- Upstream commit: `2b5103b2f8899fa6e96611389c655e189a107f3b`
|
||||
- Snapshot packages: `aem`, `clitrack`, `internal/encoder`, `internal/sender`
|
||||
|
||||
DWS carries one reviewed privacy extension on top of the upstream snapshot:
|
||||
`clitrack.Config.NoAutomaticDimensions` disables the SDK's device, operating
|
||||
system, locale, session, and other automatic dimensions. The official DWS
|
||||
entrypoint enables this mode and tests the final encoded payload as an exact
|
||||
field whitelist.
|
||||
|
||||
The upstream `v0.3.0` source tree did not contain a `LICENSE`, `NOTICE`, or
|
||||
`COPYING` file. No replacement license text has been invented in this snapshot.
|
||||
Redistribution authorization is managed by the repository owners.
|
||||
Vendored
+264
@@ -0,0 +1,264 @@
|
||||
package aem
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/internal/encoder"
|
||||
)
|
||||
|
||||
// AES 协议固定常量(不可修改,与 JS @ali/aes-tracker v3.3.18 对齐)。
|
||||
const (
|
||||
sdkVersion = "3.3.18"
|
||||
platformGo = "go"
|
||||
uuidCharset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz" // 60 字符(缺 Y 和 j)
|
||||
uuidLength = 20
|
||||
)
|
||||
|
||||
const (
|
||||
configPID = "pid"
|
||||
configAppName = "app_name"
|
||||
configEnv = "env"
|
||||
configEndpoint = "endpoint"
|
||||
configVersion = "version"
|
||||
configAsync = "async"
|
||||
configQueueSize = "queue_size"
|
||||
// configDisableAutoDimensions is a local privacy control. It is never
|
||||
// serialized; callers that enable it receive only explicitly configured
|
||||
// dimensions plus app_version derived from version.
|
||||
configDisableAutoDimensions = "disable_auto_dimensions"
|
||||
)
|
||||
|
||||
// Config 是 SDK 接入参数,key 与 AEM 协议字段保持一致。
|
||||
//
|
||||
// 仅 pid 必填,其余字段为空时由 applyDefaults 填充默认值。Go SDK 会上报
|
||||
// 服务端适用的公共维度,例如 app_name、env、version、uid、username、
|
||||
// user_type、dim1~dim10、sid、bucket_id、ext。async、queue_size、endpoint
|
||||
// 是 SDK 本地控制项,不会作为公共维度上报。
|
||||
type Config map[string]interface{}
|
||||
|
||||
var supportedSendConfigKeys = map[string]struct{}{
|
||||
configPID: {},
|
||||
configAppName: {},
|
||||
configEnv: {},
|
||||
configVersion: {},
|
||||
"user_type": {},
|
||||
"uid": {},
|
||||
"username": {},
|
||||
"sid": {},
|
||||
"bucket_id": {},
|
||||
"ext": {},
|
||||
"platform": {},
|
||||
}
|
||||
|
||||
func init() {
|
||||
for i := 1; i <= 10; i++ {
|
||||
supportedSendConfigKeys[fmt.Sprintf("dim%d", i)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneConfig(c Config) Config {
|
||||
cloned := make(Config, len(c))
|
||||
for k, v := range c {
|
||||
cloned[k] = v
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// applyDefaults 为可选字段填充默认值,并返回新的配置副本。
|
||||
func applyDefaults(c Config) Config {
|
||||
cloned := cloneConfig(c)
|
||||
if stringValue(cloned, configAppName) == "" {
|
||||
cloned[configAppName] = "unknown"
|
||||
}
|
||||
if stringValue(cloned, configEnv) == "" {
|
||||
cloned[configEnv] = "prod"
|
||||
}
|
||||
if stringValue(cloned, configEndpoint) == "" {
|
||||
cloned[configEndpoint] = "gm.mmstat.com"
|
||||
}
|
||||
if stringValue(cloned, configVersion) == "" {
|
||||
cloned[configVersion] = "unknown"
|
||||
}
|
||||
if _, ok := cloned[configAsync]; !ok {
|
||||
cloned[configAsync] = true
|
||||
}
|
||||
if intValue(cloned, configQueueSize) <= 0 {
|
||||
cloned[configQueueSize] = 1000
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// buildSendConfig 构建 gokey 编码所需的全局维度 map。
|
||||
//
|
||||
// 默认包含 AES 协议的 sdk_version/platform/device_id/os/os_version/app_name/
|
||||
// app_version/pv_id/timezone_offset 自动采集字段,加上用户配置的 pid/env/uid/
|
||||
// username/version/user_type/dim1~dim10/sid/bucket_id/ext 等公共维度。启用
|
||||
// disable_auto_dimensions 时仅保留显式配置字段和由 version 派生的 app_version。
|
||||
// 空值字段不会被写入;page_id、utm_* 等页面/浏览器字段不会在 Go SDK 中上报。
|
||||
func buildSendConfig(c Config) map[string]string {
|
||||
m := make(map[string]string, len(c)+8)
|
||||
for k, v := range c {
|
||||
if _, ok := supportedSendConfigKeys[k]; !ok {
|
||||
continue
|
||||
}
|
||||
if s, ok := encoder.ItemToString(v); ok {
|
||||
m[k] = s
|
||||
}
|
||||
}
|
||||
if boolValue(c, configDisableAutoDimensions) {
|
||||
if v, ok := m["version"]; ok && v != "" {
|
||||
m["app_version"] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
m["sdk_version"] = sdkVersion
|
||||
if _, ok := m["platform"]; !ok {
|
||||
m["platform"] = platformGo
|
||||
}
|
||||
m["device_id"] = getDeviceID()
|
||||
m["os"] = capitalizeOS()
|
||||
m["os_version"] = getOSVersion()
|
||||
if v, ok := m["version"]; ok && v != "" {
|
||||
m["app_version"] = v
|
||||
} else {
|
||||
m["app_version"] = runtime.Version()
|
||||
}
|
||||
m["pv_id"] = generateUUID()
|
||||
m["timezone_offset"] = getTimezoneOffset()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func stringValue(c Config, key string) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := c[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := encoder.ItemToString(v)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func boolValue(c Config, key string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
v, ok := c[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
case string:
|
||||
b, err := strconv.ParseBool(val)
|
||||
return err == nil && b
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(c Config, key string) int {
|
||||
s := stringValue(c, key)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// getMAC 返回第一个非零的网卡 MAC 地址;获取失败时返回 "00:00:00:00:00:00"。
|
||||
func getMAC() string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "00:00:00:00:00:00"
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
mac := iface.HardwareAddr
|
||||
if len(mac) == 0 {
|
||||
continue
|
||||
}
|
||||
macStr := mac.String()
|
||||
if macStr != "" && macStr != "00:00:00:00:00:00" {
|
||||
return macStr
|
||||
}
|
||||
}
|
||||
return "00:00:00:00:00:00"
|
||||
}
|
||||
|
||||
// getDeviceID 用 MAC 地址的 MD5 hex 作为设备指纹。
|
||||
func getDeviceID() string {
|
||||
hash := md5.Sum([]byte(getMAC()))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// getOSVersion 调用系统命令获取内核版本字符串。
|
||||
func getOSVersion() string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
out, err := exec.Command("cmd", "/c", "ver").Output()
|
||||
if err != nil {
|
||||
return "Windows"
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
default:
|
||||
out, err := exec.Command("uname", "-r").Output()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
}
|
||||
|
||||
// getTimezoneOffset 对齐 JS Date.getTimezoneOffset()。东八区返回 "-480"。
|
||||
func getTimezoneOffset() string {
|
||||
_, offset := time.Now().Zone()
|
||||
return fmt.Sprintf("%d", -(offset / 60))
|
||||
}
|
||||
|
||||
// capitalizeOS 把 runtime.GOOS 首字母大写("darwin" → "Darwin")。
|
||||
func capitalizeOS() string {
|
||||
osName := runtime.GOOS
|
||||
if len(osName) == 0 {
|
||||
return osName
|
||||
}
|
||||
return strings.ToUpper(osName[:1]) + osName[1:]
|
||||
}
|
||||
|
||||
// generateUUID 生成 20 字符的随机 ID,字符集与 JS 版严格一致。
|
||||
//
|
||||
// 使用 rejection sampling 保证字符分布均匀(拒绝阈值 240 = 60*4)。
|
||||
func generateUUID() string {
|
||||
charsetLen := len(uuidCharset) // 60
|
||||
maxByte := byte(256 - 256%charsetLen) // 240
|
||||
result := make([]byte, uuidLength)
|
||||
buf := make([]byte, 1)
|
||||
for i := 0; i < uuidLength; i++ {
|
||||
for {
|
||||
_, _ = rand.Read(buf)
|
||||
if buf[0] < maxByte {
|
||||
result[i] = uuidCharset[buf[0]%byte(charsetLen)]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package aem
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildSendConfigPublishesCLIIdentityAndVersion(t *testing.T) {
|
||||
got := buildSendConfig(Config{
|
||||
"pid": "pid-1",
|
||||
"app_name": "dws",
|
||||
"version": "v1.2.3",
|
||||
"uid": "user-1",
|
||||
})
|
||||
|
||||
for key, want := range map[string]string{
|
||||
"version": "v1.2.3",
|
||||
"app_version": "v1.2.3",
|
||||
"uid": "user-1",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Fatalf("send config %s = %q, want %q", key, got[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSendConfigCanDisableAutomaticDimensions(t *testing.T) {
|
||||
got := buildSendConfig(Config{
|
||||
"pid": "pid-1",
|
||||
"app_name": "dws",
|
||||
"env": "prod",
|
||||
"version": "v1.2.3",
|
||||
"platform": "cli",
|
||||
"uid": "user-1",
|
||||
"username": "Alice",
|
||||
configDisableAutoDimensions: true,
|
||||
})
|
||||
|
||||
gotKeys := make([]string, 0, len(got))
|
||||
for key := range got {
|
||||
gotKeys = append(gotKeys, key)
|
||||
}
|
||||
sort.Strings(gotKeys)
|
||||
wantKeys := []string{"app_name", "app_version", "env", "pid", "platform", "uid", "username", "version"}
|
||||
if !slices.Equal(gotKeys, wantKeys) {
|
||||
t.Fatalf("privacy send config keys = %v, want %v", gotKeys, wantKeys)
|
||||
}
|
||||
for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} {
|
||||
if _, ok := got[key]; ok {
|
||||
t.Fatalf("privacy send config contains automatic dimension %q: %#v", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
package aem
|
||||
|
||||
// Event 是一次上报的载荷。Type 决定字段语义(api/event 等),
|
||||
// Fields 携带 p1~p20、c1~c10、ext 以及 url、status 等业务维度。
|
||||
//
|
||||
// 字段命名约定:
|
||||
// - 通用字段:type、ts(毫秒)
|
||||
// - 自定义事件:type=event,p1 为事件 ID,p4 为事件类型(EXP/CLK/SLD/INPUT/SYS/OTHER)
|
||||
// - 平台保留:p1~p20(不同 type 含义不同,详见 AES 协议文档)
|
||||
// - 用户自定义:c1~c10
|
||||
// - 扩展 JSON:ext
|
||||
//
|
||||
// Fields 中值为空字符串的键会在序列化时被自动丢弃。
|
||||
type Event struct {
|
||||
// Type 事件类型,如 "api"、"event"。必填。
|
||||
Type string
|
||||
|
||||
// Fields 业务字段。Track 时会补全 ts;其余键的语义由调用方决定。
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
// toMap 把 Event 序列化为 SDK 内部使用的 map 形式,过滤空值。
|
||||
//
|
||||
// 注意:返回的 map 是新分配的,调用方修改不会影响原 Event。
|
||||
func (e Event) toMap() map[string]string {
|
||||
m := make(map[string]string, len(e.Fields)+1)
|
||||
if e.Type != "" {
|
||||
m["type"] = e.Type
|
||||
}
|
||||
for k, v := range e.Fields {
|
||||
if v != "" {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
// Package aem 提供阿里巴巴 AEM (Application Experience Management) 平台的 Go 上报 SDK。
|
||||
//
|
||||
// 它适用于任何 Go 程序(web server、cron job、library 等),通过 Tracker.Track
|
||||
// 将埋点事件加入后台队列并异步上报到 AES 后端。
|
||||
//
|
||||
// 简单用法:
|
||||
//
|
||||
// tracker := aem.NewTracker(aem.Config{
|
||||
// "pid": "your_project_id",
|
||||
// "app_name": "my-service",
|
||||
// "env": "prod",
|
||||
// "version": "1.0.0",
|
||||
// })
|
||||
// defer tracker.Close()
|
||||
//
|
||||
// tracker.Track(aem.Event{
|
||||
// Type: "api",
|
||||
// Fields: map[string]string{
|
||||
// "url": "/api/user", "status": "200", "duration": "120",
|
||||
// },
|
||||
// })
|
||||
package aem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/internal/encoder"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/internal/sender"
|
||||
)
|
||||
|
||||
// ErrQueueFull 表示异步上报队列已满,本次事件没有入队。
|
||||
var ErrQueueFull = errors.New("aem: async queue is full")
|
||||
|
||||
// ErrTrackerClosed 表示 Tracker 已关闭,不能再接收新的事件。
|
||||
var ErrTrackerClosed = errors.New("aem: tracker is closed")
|
||||
|
||||
// Tracker 是 SDK 的核心入口,封装了配置、全局维度和上报通道。
|
||||
//
|
||||
// 一个进程通常只创建一个 Tracker,并发调用 Track 是安全的。生命周期内
|
||||
// sendCfg 只构建一次以降低开销。
|
||||
type Tracker struct {
|
||||
config Config
|
||||
sendCfg map[string]string
|
||||
|
||||
async bool
|
||||
queue chan string
|
||||
workerDone chan struct{}
|
||||
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
asyncErr error
|
||||
}
|
||||
|
||||
// NewTracker 创建并初始化一个 Tracker。默认会立刻填充并采集设备维度
|
||||
// (MAC、OS、PVID 等);启用 disable_auto_dimensions 时跳过这些采集。
|
||||
func NewTracker(c Config) *Tracker {
|
||||
cfg := applyDefaults(c)
|
||||
t := &Tracker{
|
||||
config: cfg,
|
||||
sendCfg: buildSendConfig(cfg),
|
||||
async: boolValue(cfg, configAsync),
|
||||
}
|
||||
if t.async {
|
||||
t.queue = make(chan string, intValue(cfg, configQueueSize))
|
||||
t.workerDone = make(chan struct{})
|
||||
go t.runWorker()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Track 上报一个事件,失败时返回 error。
|
||||
//
|
||||
// 如果 Event 没有自带 ts 字段,Track 会自动补 time.Now().UnixMilli()。
|
||||
// 默认 async=true 时,Track 只负责把事件写入内存队列,不等待远端 HTTP 请求完成;
|
||||
// 返回 error 仅表示参数校验、队列已满或 Tracker 已关闭。async=false 时,Track
|
||||
// 会在当前 goroutine 内同步发送并返回远端发送结果。
|
||||
func (t *Tracker) Track(event Event) error {
|
||||
if stringValue(t.config, configPID) == "" {
|
||||
return errors.New(`aem: config field "pid" is required`)
|
||||
}
|
||||
if event.Type == "" {
|
||||
return errors.New("aem: Event.Type is required")
|
||||
}
|
||||
|
||||
fields := event.toMap()
|
||||
if _, ok := fields["ts"]; !ok {
|
||||
fields["ts"] = fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
gokey := encoder.ProcessData([]map[string]string{fields}, t.sendCfg)
|
||||
|
||||
if !t.async {
|
||||
t.mu.RLock()
|
||||
closed := t.closed
|
||||
t.mu.RUnlock()
|
||||
if closed {
|
||||
return ErrTrackerClosed
|
||||
}
|
||||
return t.send(gokey)
|
||||
}
|
||||
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
if t.closed {
|
||||
return ErrTrackerClosed
|
||||
}
|
||||
select {
|
||||
case t.queue <- gokey:
|
||||
return nil
|
||||
default:
|
||||
return ErrQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) runWorker() {
|
||||
defer close(t.workerDone)
|
||||
for gokey := range t.queue {
|
||||
if err := t.send(gokey); err != nil {
|
||||
t.asyncErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// userAgent builds the User-Agent header from config: "app_name/version".
|
||||
func (t *Tracker) userAgent() string {
|
||||
name := stringValue(t.config, configAppName)
|
||||
ver := stringValue(t.config, configVersion)
|
||||
if name == "" || name == "unknown" {
|
||||
return ""
|
||||
}
|
||||
if ver != "" && ver != "unknown" {
|
||||
return name + "/" + ver
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (t *Tracker) send(gokey string) error {
|
||||
return sender.Send(stringValue(t.config, configEndpoint), gokey, t.userAgent())
|
||||
}
|
||||
|
||||
// Config 返回 Tracker 当前使用的配置(副本)。
|
||||
//
|
||||
// 返回值是副本,修改它不会影响 Tracker 内部配置。
|
||||
func (t *Tracker) Config() Config {
|
||||
return cloneConfig(t.config)
|
||||
}
|
||||
|
||||
// Close 释放 Tracker 持有的资源。
|
||||
//
|
||||
// async=true 时,Close 会停止接收新事件,并等待队列中已入队的事件发送完成。
|
||||
// 如果后台发送发生错误,Close 返回最后一次发送错误。
|
||||
func (t *Tracker) Close() error {
|
||||
if !t.async {
|
||||
t.mu.Lock()
|
||||
t.closed = true
|
||||
t.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
if !t.closed {
|
||||
t.closed = true
|
||||
close(t.queue)
|
||||
}
|
||||
workerDone := t.workerDone
|
||||
t.mu.Unlock()
|
||||
|
||||
<-workerDone
|
||||
return t.asyncErr
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package clitrack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// captureStdout 拦截 stdout,将 fn 的输出同时写到原 stdout 和 buffer。
|
||||
// 返回捕获到的输出内容和 fn 的 error。Pipe 创建失败时降级为不捕获,
|
||||
// 保证 fn 一定被执行。
|
||||
//
|
||||
// 注意:这会临时替换 os.Stdout,对依赖 TTY 检测、进度条、ANSI 颜色的 CLI
|
||||
// 可能有副作用——这正是 CaptureOutput 默认关闭的原因。
|
||||
func captureStdout(fn func() error) (string, error) {
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
return "", fn()
|
||||
}
|
||||
os.Stdout = w
|
||||
|
||||
var buf bytes.Buffer
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(io.MultiWriter(old, &buf), r)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
execErr := fn()
|
||||
|
||||
w.Close()
|
||||
<-done
|
||||
os.Stdout = old
|
||||
|
||||
return buf.String(), execErr
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
package clitrack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/aem"
|
||||
)
|
||||
|
||||
// 默认值。
|
||||
const (
|
||||
defaultEventID = "cli.exec" // p1:AEM 自定义事件 ID
|
||||
eventTypeSys = "SYS" // p4:AEM 事件类型,系统事件
|
||||
defaultOutputLen = 500 // c8 截断长度
|
||||
maxErrorLen = 200 // c5 截断长度
|
||||
)
|
||||
|
||||
// defaultFlushTimeout 是 CLI 退出前等待异步队列 flush 的最长时间。
|
||||
// 到点未发完就放弃上报直接退出——宁可丢这条埋点,也不让用户等。
|
||||
const defaultFlushTimeout = 300 * time.Millisecond
|
||||
|
||||
// Config 是 clitrack 接入配置。只有 PID 必填,其余都有合理默认值。
|
||||
type Config struct {
|
||||
// —— 必填 ——
|
||||
PID string // AEM 项目 ID
|
||||
|
||||
// —— 应用维度 ——
|
||||
App string // CLI 名称,默认 "unknown"
|
||||
Env string // 环境:prod/pre/daily,默认 prod
|
||||
Version string // CLI 版本号(建议用 ldflags 注入)
|
||||
|
||||
// —— 用户维度(可选,接入方自己填,本包不读取任何凭据文件)——
|
||||
UID string // 用户 ID,如工号
|
||||
Username string // 用户名
|
||||
UserType string // 账号类型,如 "14"
|
||||
|
||||
// —— 行为 ——
|
||||
EventID string // p1 事件 ID,默认 "cli.exec"
|
||||
Endpoint string // 上报域名,海外站点填 sg.mmstat.com;默认走 SDK 默认
|
||||
|
||||
// CaptureOutput 控制是否捕获 stdout 到 c8。默认 false。
|
||||
// 捕获会用 os.Pipe 劫持 os.Stdout,可能干扰进度条/TTY 检测/颜色输出,
|
||||
// 仅在确认 CLI 输出适合采集时开启。
|
||||
CaptureOutput bool
|
||||
OutputMaxLen int // c8 截断长度,默认 500;仅在 CaptureOutput 时生效
|
||||
|
||||
// FlushTimeout 是退出前等待上报完成的最长时间,默认 300ms。
|
||||
FlushTimeout time.Duration
|
||||
|
||||
// —— 字段级隐私开关(给接入开发者的编译期选项,默认采集)——
|
||||
NoCommandLine bool // 不采 c2 完整命令行(命令行常带敏感参数时设 true)
|
||||
NoCwd bool // 不采 c7 工作目录
|
||||
// NoAutomaticDimensions 只保留接入方显式配置的公共维度,并关闭
|
||||
// device_id、os、os_version、timezone_offset、pv_id、sdk_version、sid、
|
||||
// ext.language 与 c6 Shell 自动采集。
|
||||
NoAutomaticDimensions bool
|
||||
|
||||
// —— 扩展钩子 ——
|
||||
// ExtraFields 返回的字段会合并进事件,用于补充 c9/c10/ext 等自定义维度。
|
||||
// 不要覆盖 c1~c8 的约定语义,否则破坏跨 CLI 聚合。空值字段会被忽略。
|
||||
ExtraFields func() map[string]string
|
||||
}
|
||||
|
||||
// Tracker 是埋点实例,通过 New 创建,通过 Run 执行 CLI 并自动上报。
|
||||
type Tracker struct {
|
||||
inner *aem.Tracker
|
||||
|
||||
eventID string
|
||||
captureOutput bool
|
||||
outputMaxLen int
|
||||
flushTimeout time.Duration
|
||||
noCommandLine bool
|
||||
noCwd bool
|
||||
noAutomaticDimensions bool
|
||||
extraFields func() map[string]string
|
||||
}
|
||||
|
||||
// New 根据配置创建 Tracker。
|
||||
//
|
||||
// 如果 PID 为空,返回空实例:Run 仍可正常执行 CLI,只是不上报。这样接入方
|
||||
// 在缺少 PID(如本地开发)时无需加任何判断,埋点自动降级为 no-op。
|
||||
func New(cfg Config) *Tracker {
|
||||
if cfg.PID == "" {
|
||||
return &Tracker{}
|
||||
}
|
||||
|
||||
env := cfg.Env
|
||||
if env == "" {
|
||||
env = "prod"
|
||||
}
|
||||
app := cfg.App
|
||||
if app == "" {
|
||||
app = "unknown"
|
||||
}
|
||||
eventID := cfg.EventID
|
||||
if eventID == "" {
|
||||
eventID = defaultEventID
|
||||
}
|
||||
outputMaxLen := cfg.OutputMaxLen
|
||||
if outputMaxLen <= 0 {
|
||||
outputMaxLen = defaultOutputLen
|
||||
}
|
||||
flushTimeout := cfg.FlushTimeout
|
||||
if flushTimeout <= 0 {
|
||||
flushTimeout = defaultFlushTimeout
|
||||
}
|
||||
|
||||
aemCfg := aem.Config{
|
||||
"pid": cfg.PID,
|
||||
"app_name": app,
|
||||
"env": env,
|
||||
"version": cfg.Version,
|
||||
"platform": "cli",
|
||||
}
|
||||
if cfg.NoAutomaticDimensions {
|
||||
aemCfg["disable_auto_dimensions"] = true
|
||||
}
|
||||
if cfg.Endpoint != "" {
|
||||
aemCfg["endpoint"] = cfg.Endpoint
|
||||
}
|
||||
if cfg.UID != "" {
|
||||
aemCfg["uid"] = cfg.UID
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
aemCfg["username"] = cfg.Username
|
||||
}
|
||||
if cfg.UserType != "" {
|
||||
aemCfg["user_type"] = cfg.UserType
|
||||
}
|
||||
|
||||
if !cfg.NoAutomaticDimensions {
|
||||
// sid:终端会话 ID,自动从环境变量采集(数据维度,非配置开关)。
|
||||
sid := os.Getenv("TERM_SESSION_ID")
|
||||
if sid == "" {
|
||||
sid = os.Getenv("TMUX_PANE")
|
||||
}
|
||||
if sid != "" {
|
||||
aemCfg["sid"] = sid
|
||||
}
|
||||
|
||||
// ext.language:终端 locale,自动采集。
|
||||
lang := os.Getenv("LANG")
|
||||
if lang == "" {
|
||||
lang = os.Getenv("LC_ALL")
|
||||
}
|
||||
if lang != "" {
|
||||
aemCfg["ext"] = fmt.Sprintf(`{"language":%q}`, lang)
|
||||
}
|
||||
}
|
||||
|
||||
return &Tracker{
|
||||
inner: aem.NewTracker(aemCfg),
|
||||
eventID: eventID,
|
||||
captureOutput: cfg.CaptureOutput,
|
||||
outputMaxLen: outputMaxLen,
|
||||
flushTimeout: flushTimeout,
|
||||
noCommandLine: cfg.NoCommandLine,
|
||||
noCwd: cfg.NoCwd,
|
||||
noAutomaticDimensions: cfg.NoAutomaticDimensions,
|
||||
extraFields: cfg.ExtraFields,
|
||||
}
|
||||
}
|
||||
|
||||
// Run 执行 CLI 主函数并自动上报埋点。
|
||||
//
|
||||
// 自动完成:计时、从 os.Args 采集入参、推导退出码、(可选)捕获 stdout、
|
||||
// 错误输出到 stderr、best-effort flush(带超时,不阻塞退出)。
|
||||
//
|
||||
// execute CLI 入口,返回 error;cobra 直接传 rootCmd.Execute。
|
||||
// exitCode 把 error 映射为退出码;传 nil 用默认映射(nil→0,其余→1)。
|
||||
//
|
||||
// 与现状一致:退出码非 0 时调用 os.Exit;为 0 时正常 return,不调 os.Exit。
|
||||
func (t *Tracker) Run(execute func() error, exitCode func(error) int) {
|
||||
if exitCode == nil {
|
||||
exitCode = defaultExitCode
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
var output string
|
||||
var err error
|
||||
if t.captureOutput {
|
||||
output, err = captureStdout(execute)
|
||||
} else {
|
||||
err = execute()
|
||||
}
|
||||
|
||||
code := 0
|
||||
var errStr string
|
||||
if err != nil {
|
||||
code = exitCode(err)
|
||||
errStr = err.Error()
|
||||
if errStr != "" {
|
||||
fmt.Fprintln(os.Stderr, errStr)
|
||||
}
|
||||
}
|
||||
|
||||
t.trackExec(code, time.Since(start), errStr, output)
|
||||
t.close()
|
||||
|
||||
if code != 0 {
|
||||
os.Exit(code)
|
||||
}
|
||||
}
|
||||
|
||||
// trackExec 上报一次命令执行事件。
|
||||
func (t *Tracker) trackExec(exitCode int, duration time.Duration, errMsg, output string) {
|
||||
if t.inner == nil {
|
||||
return
|
||||
}
|
||||
_ = t.inner.Track(aem.Event{
|
||||
Type: "event",
|
||||
Fields: t.buildFields(exitCode, duration, errMsg, output),
|
||||
})
|
||||
}
|
||||
|
||||
// buildFields 按字段约定组装一次命令执行的事件字段(纯函数,便于测试)。
|
||||
func (t *Tracker) buildFields(exitCode int, duration time.Duration, errMsg, output string) map[string]string {
|
||||
fields := map[string]string{
|
||||
"p1": t.eventID,
|
||||
"p4": eventTypeSys,
|
||||
"c1": command(),
|
||||
"c3": strconv.Itoa(exitCode),
|
||||
"c4": strconv.FormatInt(duration.Milliseconds(), 10),
|
||||
}
|
||||
if !t.noAutomaticDimensions {
|
||||
fields["c6"] = shellType()
|
||||
}
|
||||
if !t.noCommandLine {
|
||||
fields["c2"] = commandLine()
|
||||
}
|
||||
if !t.noCwd {
|
||||
fields["c7"] = cwd()
|
||||
}
|
||||
if errMsg != "" {
|
||||
fields["c5"] = truncate(errMsg, maxErrorLen)
|
||||
}
|
||||
if output != "" {
|
||||
fields["c8"] = truncate(output, t.outputMaxLen)
|
||||
}
|
||||
if t.extraFields != nil {
|
||||
for k, v := range t.extraFields() {
|
||||
if v != "" {
|
||||
fields[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// close 关闭内部 Tracker,best-effort flush:最多等 flushTimeout,超时即放弃。
|
||||
func (t *Tracker) close() {
|
||||
if t.inner == nil {
|
||||
return
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = t.inner.Close()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(t.flushTimeout):
|
||||
}
|
||||
}
|
||||
|
||||
// defaultExitCode 是 exitCode 参数为 nil 时的默认映射。
|
||||
func defaultExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package clitrack
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildFieldsKeepsOrganizationDimension(t *testing.T) {
|
||||
tracker := &Tracker{
|
||||
noCommandLine: true,
|
||||
noCwd: true,
|
||||
extraFields: func() map[string]string {
|
||||
return map[string]string{"c9": "version", "c10": "corp-1"}
|
||||
},
|
||||
}
|
||||
|
||||
fields := tracker.buildFields(0, time.Millisecond, "", "")
|
||||
if fields["c9"] != "version" || fields["c10"] != "corp-1" {
|
||||
t.Fatalf("custom telemetry fields = %#v", fields)
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// Package clitrack 在 aem 核心 SDK 之上,为任意 Go CLI 提供零侵入的使用埋点。
|
||||
//
|
||||
// 设计理念:CLI 入口只需一行 clitrack.New(cfg).Run(...),剩下全部自动完成——
|
||||
// 从 os.Args 采集原始入参、自动计时、自动推导命令路径和退出码、异步上报。
|
||||
//
|
||||
// 埋点尽力而为,数据可丢,但绝不阻塞 CLI 退出(见 FlushTimeout)。接入方负责
|
||||
// 向最终用户披露采集范围并提供符合其产品要求的退出机制。
|
||||
//
|
||||
// 最小接入示例(以 cobra CLI 为例):
|
||||
//
|
||||
// func Execute() {
|
||||
// clitrack.New(clitrack.Config{
|
||||
// PID: "your-pid", App: "my-cli", Version: version,
|
||||
// }).Run(rootCmd.Execute, nil)
|
||||
// }
|
||||
//
|
||||
// 框架无关:Run 只要求一个 func() error 入口,cobra / urfave-cli / 标准库 flag
|
||||
// 都能套。exitCode 传 nil 时用默认映射(nil→0,其余→1)。
|
||||
//
|
||||
// AEM 字段映射约定(所有接入的 CLI 统一遵守,这是跨 CLI 聚合分析的基础):
|
||||
//
|
||||
// Config 维度(初始化时设一次):
|
||||
// pid → Config.PID AEM 项目 ID(必填)
|
||||
// app_name → Config.App CLI 名称
|
||||
// env → Config.Env 环境 (prod/pre/daily),默认 prod
|
||||
// version → Config.Version CLI 版本号
|
||||
// uid → Config.UID 用户 ID(可选,接入方自己填)
|
||||
// username → Config.Username 用户名(可选)
|
||||
// user_type → Config.UserType 账号类型(可选)
|
||||
// endpoint → Config.Endpoint 上报域名(可选,海外站点填 sg.mmstat.com)
|
||||
// sid → $TERM_SESSION_ID 终端会话 ID(自动采集,NoAutomaticDimensions 可关)
|
||||
// ext.language → $LANG 终端 locale(自动采集,NoAutomaticDimensions 可关)
|
||||
//
|
||||
// Event 维度(每次命令执行打一条,type = "event"):
|
||||
// p1 → "cli.exec" AEM 自定义事件 ID(默认值,可用 Config.EventID 覆盖)
|
||||
// p4 → "SYS" AEM 事件类型:系统事件(固定值)
|
||||
// c1 → command filepath.Base(os.Args[0])(CLI 二进制名,如 "aem")
|
||||
// c2 → command_line os.Args[1:] 拼接(完整参数);Config.NoCommandLine 可关
|
||||
// c3 → exit_code 退出码
|
||||
// c4 → duration_ms 执行耗时(毫秒)
|
||||
// c5 → error_message 错误摘要,截断 200 字符
|
||||
// c6 → shell_type Shell 类型(zsh/bash 等);NoAutomaticDimensions 可关
|
||||
// c7 → cwd 当前工作目录;Config.NoCwd 可关
|
||||
// c8 → output CLI stdout 输出摘要;默认不采,Config.CaptureOutput 显式开启
|
||||
// c9/c10/ext → 自定义 由 Config.ExtraFields 钩子返回
|
||||
//
|
||||
// Config.NoAutomaticDimensions 会关闭设备、操作系统、时区、随机会话、终端会话、
|
||||
// locale 和 Shell 等自动维度,只保留接入方显式配置的公共维度和事件字段。本包不读取
|
||||
// 产品级退出环境变量;接入应用应在创建 Tracker 前执行自己的退出策略。PID 为空时
|
||||
// Tracker 自动降级为 no-op,命令仍正常执行。
|
||||
package clitrack
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package clitrack
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// command 返回 CLI 二进制名(c1),如 "aem"。
|
||||
func command() string {
|
||||
return filepath.Base(os.Args[0])
|
||||
}
|
||||
|
||||
// commandLine 返回完整命令行参数(c2),如 "login --env prod"。
|
||||
func commandLine() string {
|
||||
return strings.Join(os.Args[1:], " ")
|
||||
}
|
||||
|
||||
// shellType 返回当前 Shell 类型(c6),取 $SHELL 的 basename(如 "zsh"、"bash")。
|
||||
func shellType() string {
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(shell)
|
||||
}
|
||||
|
||||
// cwd 返回当前工作目录(c7),失败返回空字符串。
|
||||
func cwd() string {
|
||||
dir, _ := os.Getwd()
|
||||
return dir
|
||||
}
|
||||
|
||||
// truncate 按 rune 截断字符串到 maxLen,超出部分用 "..." 替换。
|
||||
func truncate(s string, maxLen int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
return string(runes[:maxLen-3]) + "..."
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
module gitlab.alibaba-inc.com/aes/aem-go-sdk
|
||||
|
||||
go 1.25.7
|
||||
@@ -0,0 +1,97 @@
|
||||
package encoder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func EncodeURIComponent(s string) string {
|
||||
result := url.QueryEscape(s)
|
||||
result = strings.ReplaceAll(result, "+", "%20")
|
||||
result = strings.ReplaceAll(result, "%21", "!")
|
||||
result = strings.ReplaceAll(result, "%27", "'")
|
||||
result = strings.ReplaceAll(result, "%28", "(")
|
||||
result = strings.ReplaceAll(result, "%29", ")")
|
||||
result = strings.ReplaceAll(result, "%2A", "*")
|
||||
return result
|
||||
}
|
||||
|
||||
func ItemToString(v interface{}) (string, bool) {
|
||||
if v == nil {
|
||||
return "", false
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
if val == "" {
|
||||
return "", false
|
||||
}
|
||||
return val, true
|
||||
case float64:
|
||||
if val == float64(int64(val)) {
|
||||
return fmt.Sprintf("%d", int64(val)), true
|
||||
}
|
||||
return fmt.Sprintf("%g", val), true
|
||||
case int:
|
||||
return fmt.Sprintf("%d", val), true
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", val), true
|
||||
case bool:
|
||||
if val {
|
||||
return "true", true
|
||||
}
|
||||
return "false", true
|
||||
case json.Number:
|
||||
return val.String(), true
|
||||
case map[string]interface{}, []interface{}:
|
||||
b, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func ObjToQS(m map[string]string) string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(m))
|
||||
for _, k := range keys {
|
||||
v := m[k]
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, k+"="+EncodeURIComponent(v))
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func ToStringMap(data map[string]interface{}) map[string]string {
|
||||
result := make(map[string]string, len(data))
|
||||
for k, v := range data {
|
||||
if s, ok := ItemToString(v); ok {
|
||||
result[k] = s
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ProcessData(logs []map[string]string, config map[string]string) string {
|
||||
configQS := ObjToQS(config)
|
||||
|
||||
logParts := make([]string, 0, len(logs))
|
||||
for _, log := range logs {
|
||||
logParts = append(logParts, ObjToQS(log))
|
||||
}
|
||||
logsJoined := strings.Join(logParts, "|")
|
||||
|
||||
return configQS + "&msg=" + EncodeURIComponent(logsJoined)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/internal/encoder"
|
||||
)
|
||||
|
||||
const httpTimeout = 5 * time.Second
|
||||
|
||||
var httpClient = &http.Client{Timeout: httpTimeout}
|
||||
|
||||
// Send 单次 HTTP POST 上报 gokey 到 AES 后端,不重试。
|
||||
//
|
||||
// endpoint 不带 scheme 时默认 https://;以 http:// 或 https:// 开头时按原样使用。
|
||||
// 路径固定为 /aes.1.1,请求体为 {"gokey": encodeURIComponent(gokey), "gmkey": "EXP"}。
|
||||
func Send(endpoint string, gokey string, userAgent string) error {
|
||||
u := buildURL(endpoint)
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"gokey": encoder.EncodeURIComponent(gokey),
|
||||
"gmkey": "EXP",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", u, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if userAgent != "" {
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildURL(endpoint string) string {
|
||||
endpoint = strings.TrimRight(endpoint, "/")
|
||||
var u string
|
||||
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
|
||||
u = endpoint
|
||||
} else {
|
||||
u = fmt.Sprintf("https://%s", endpoint)
|
||||
}
|
||||
if strings.HasSuffix(u, "/aes.1.1") {
|
||||
return u
|
||||
}
|
||||
return u + "/aes.1.1"
|
||||
}
|
||||
Reference in New Issue
Block a user