Compare commits

..
Author SHA1 Message Date
Dennis 1d3a2976cf fix(wiki): document search parameter adapter 2026-08-15 00:04:54 +08:00
43 changed files with 74 additions and 3366 deletions
-5
View File
@@ -1,5 +0,0 @@
---
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.
+9 -84
View File
@@ -151,10 +151,6 @@ 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.
@@ -513,28 +509,11 @@ jobs:
run: node .github/reviewer-routing.test.js
test-focused:
name: "Test (focused: ${{ matrix.shard }})"
name: Test (changed packages)
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
@@ -563,77 +542,23 @@ jobs:
with:
go-version-file: go.mod
- name: Select impacted packages for shard
id: select
- name: Test changed packages and reverse dependencies
shell: bash
env:
TEST_SHARD: ${{ matrix.shard }}
DWS_PACKAGE_VERSION: 0.0.0-test
run: |
set -euo pipefail
package_output="$(
./scripts/ci/changed-test-packages.sh \
list-shard "$TEST_SHARD" "$TEST_BASE_REF" "$TEST_HEAD_REF"
list "$TEST_BASE_REF" "$TEST_HEAD_REF"
)"
if [ -z "$package_output" ]; then
echo "No buildable Go package in shard $TEST_SHARD is affected by this revision." \
echo "No buildable Go package is affected by this revision." \
>> "$GITHUB_STEP_SUMMARY"
echo "affected=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# 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[@]}"
mapfile -t packages <<< "$package_output"
go test -v -race -count=1 -timeout=15m "${packages[@]}"
test-race:
name: "Test (race: ${{ matrix.shard }})"
@@ -786,7 +711,7 @@ jobs:
failed=0
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
for shard in \
"focused shards:$FOCUSED_RESULT" \
"changed packages:$FOCUSED_RESULT" \
"race shards:$RACE_RESULT" \
"release scripts:$RELEASE_SCRIPTS_RESULT" \
"cross-platform compile:$CROSS_PLATFORM_RESULT" \
@@ -815,7 +740,7 @@ jobs:
release_expected=success
fi
for shard in \
"focused shards:$FOCUSED_RESULT:$focused_expected" \
"changed packages:$FOCUSED_RESULT:$focused_expected" \
"race shards:$RACE_RESULT:$race_expected" \
"release scripts:$RELEASE_SCRIPTS_RESULT:$release_expected" \
"cross-platform compile:$CROSS_PLATFORM_RESULT:success"
-4
View File
@@ -20,10 +20,6 @@ test/cli_compat/testdata/
.gitignore
.worktrees/
.qoder/
_logs/
_docs/
_output/
vendor/
# Secrets & credentials
.env
+2 -66
View File
@@ -15,76 +15,12 @@ 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 (
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")) != ""
}
var exit = os.Exit
func main() {
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 },
)
exit(app.Execute())
}
+13 -206
View File
@@ -1,220 +1,27 @@
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 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)
}
func TestCrossPlatformCoverageMainExitsWithSuccessfulVersionCommand(t *testing.T) {
previousExit := exit
previousArgs := os.Args
t.Cleanup(func() {
exit = previousExit
os.Args = previousArgs
})
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
trackRun(clitrack.Config{}, func() error {
code := -1
exit = func(value int) {
called = true
return nil
}, nil)
if !called {
t.Fatal("default tracker did not execute callback")
code = value
}
}
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)
}
})
os.Args = []string{"dws", "version"}
main()
}
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)
if !called || code != 0 {
t.Fatalf("main exit = called %v, code %d", called, code)
}
}
-1
View File
@@ -92,7 +92,6 @@ 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 编排 / 执行字段(不算声明)
+1 -47
View File
@@ -292,7 +292,7 @@ Definition(仅声明;不可编译)
下列字段**是**框架声明面(经 `corecmd.New` 生效并嵌入 `dws.schema.*`):
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面;`Input` 是取值来源声明,经 `corecmd.New` 生效但**不**嵌入 `dws.schema.*`,能力靠 `Usage` 文案声明,见 §5.3)
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面)
- `Constraints`
- **非空** `Risk`(空值 = 运行时当只读确认,且**不**嵌入 `dws.schema.risk`)
- `ConstParams`(载荷声明;不上用户 flag 表)
@@ -673,52 +673,6 @@ 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`;
-3
View File
@@ -2,8 +2,6 @@ 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
@@ -19,7 +17,6 @@ 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
+1 -113
View File
@@ -268,7 +268,7 @@ type frameworkFailWriter struct{}
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
func TestCrossPlatformCoverageFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(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,118 +470,6 @@ 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") }
-26
View File
@@ -66,32 +66,6 @@ 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 })
+17 -101
View File
@@ -80,19 +80,10 @@ var (
rootAuthLoadTokenData = authpkg.LoadTokenData
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
rootEmitResult = output.EmitResult
rootInstallProcessSignalContext = installProcessSignalContext
)
// Execute runs the root command and returns the process exit code.
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"
func Execute() (exitCode int) {
var (
root *cobra.Command
executed *cobra.Command
@@ -100,16 +91,12 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
)
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 {
@@ -134,7 +121,6 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
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 {
@@ -158,9 +144,7 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
agentMetadata := readAgentMetadataSnapshot()
if err := agentMetadata.validationError(); err != nil {
emitEarlyAgentMetadataValidationError(err, os.Args[1:])
errorMessage = telemetryErrorSummary(err)
exitCode = apperrors.ExitCode(err)
return
return apperrors.ExitCode(err)
}
timing := NewTimingCollector()
@@ -178,13 +162,12 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
ctx, resultStore = output.WithResultStore(ctx)
var signalState *processSignalState
var stopSignals func()
ctx, signalState, stopSignals = rootInstallProcessSignalContext(ctx, resultStore)
ctx, signalState, stopSignals = installProcessSignalContext(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.
@@ -199,23 +182,15 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
code, emitErr := output.EmitResult(target, result)
if emitErr == nil {
errorMessage = telemetryErrorSummary(err)
exitCode = code
return
return code
}
}
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
errorMessage = telemetryErrorSummary(err)
exitCode = apperrors.ExitCode(err)
return
return apperrors.ExitCode(err)
}
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
@@ -247,9 +222,7 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
// 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.
errorMessage = telemetryErrorSummary(interrupted)
exitCode = code
return
return code
}
}
var publicationErr *outputPublicationError
@@ -260,26 +233,20 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
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 {
exitCode = failureCode
return
return failureCode
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
}
exitCode = apperrors.ExitCode(publicationErr)
return
return apperrors.ExitCode(publicationErr)
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
errorMessage = telemetryErrorSummary(err)
exitCode = code
return
return code
}
err = rewordRequiredFlagError(err)
var raw apperrors.RawStderrError
@@ -287,9 +254,7 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
code, emitErr := output.EmitResult(executed, result)
if emitErr == nil {
errorMessage = telemetryErrorSummary(err)
exitCode = code
return
return code
}
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
}
@@ -299,42 +264,12 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
_, _ = fmt.Fprintln(os.Stderr)
}
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
errorMessage = telemetryErrorSummary(err)
exitCode = apperrors.ExitCode(err)
return
return apperrors.ExitCode(err)
}
if code, emitted := output.StoredExitCode(resultStore); emitted {
exitCode = code
return
return code
}
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)
return 0
}
// emitEarlyAgentMetadataValidationError preserves each built-in command's
@@ -1024,36 +959,17 @@ 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":
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 arg == "--profile" && i+1 < len(args):
return strings.TrimSpace(args[i+1])
case strings.HasPrefix(arg, "--profile="):
specified = true
profile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
valid = profile != ""
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
}
}
return profile, specified, valid
return ""
}
func normalizeProcessProfileArgs() func() {
+10 -45
View File
@@ -37,64 +37,29 @@ 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 {
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
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
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)
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
if code := Execute(); code != 0 {
t.Fatalf("successful Execute code = %d", code)
}
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return errors.New("alias/canonical conflict") }
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "alias/canonical conflict" {
t.Fatalf("pre-parse conflict = code %d error %q", code, errorMessage)
if code := Execute(); code == 0 {
t.Fatal("pre-parse conflict returned zero")
}
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, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "unknown command" {
t.Fatalf("failed ExecuteWithTelemetry = code %d error %q", code, errorMessage)
if code := Execute(); code == 0 {
t.Fatal("failed Execute returned zero")
}
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") }
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)
if code := Execute(); code != 5 {
t.Fatalf("panic Execute code = %d", code)
}
}
@@ -181,7 +181,7 @@ func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
}
}
func TestCrossPlatformCoverageExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
+3 -10
View File
@@ -230,20 +230,13 @@ func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing
assertNoOutputTemps(t, target)
}
func TestCrossPlatformCoverageExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
testseam.Protect(t, &os.Args)
dir := t.TempDir()
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"
target := filepath.Join(dir, "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() {} })
@@ -284,7 +277,7 @@ func TestCrossPlatformCoverageExecuteUnifiedPublicationFailureEmitsFailureOnOrig
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", originalInfo.Mode().Perm())
assertOutputFile(t, target, "original", 0o640)
assertNoOutputTemps(t, target)
}
+1 -1
View File
@@ -27,7 +27,7 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
+3 -3
View File
@@ -103,7 +103,7 @@ func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Wri
})
}
func TestCrossPlatformCoverageExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
for _, tc := range []struct {
name string
signal syscall.Signal
@@ -197,7 +197,7 @@ func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing
}
}
func TestCrossPlatformCoverageSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(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 TestCrossPlatformCoverageSignalBeforeEmissionAttemptPreservesPublishedOutco
}
}
func TestCrossPlatformCoverageSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
-143
View File
@@ -1,143 +0,0 @@
// 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]) + "..."
}
-99
View File
@@ -1,99 +0,0 @@
// 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)
}
}
-86
View File
@@ -1,86 +0,0 @@
// 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, "")
}
-147
View File
@@ -1,147 +0,0 @@
// 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)
}
}
@@ -1,70 +0,0 @@
// 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
}
@@ -1,97 +0,0 @@
// 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)
}
}
-15
View File
@@ -145,15 +145,6 @@ 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)
@@ -374,7 +365,6 @@ 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.
@@ -486,11 +476,6 @@ 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
}
-191
View File
@@ -1,191 +0,0 @@
// 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
}
-334
View File
@@ -1,334 +0,0 @@
// 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 },
})
})
}
}
-1
View File
@@ -217,7 +217,6 @@ 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
-6
View File
@@ -107,12 +107,6 @@ 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
+9 -46
View File
@@ -7,32 +7,16 @@ set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
usage() {
printf '%s\n' \
"usage: $0 <changed|list> BASE_REF HEAD_REF" \
" $0 list-shard SHARD BASE_REF HEAD_REF" >&2
printf '%s\n' "usage: $0 <changed|list> BASE_REF HEAD_REF" >&2
exit 2
}
# 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
[ "${1:-}" = changed ] || [ "${1:-}" = list ] || usage
[ "$#" -eq 3 ] || usage
MODE="$1"
BASE_REF="$2"
HEAD_REF="$3"
cd "$ROOT"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
@@ -132,29 +116,9 @@ 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
emit_selection "$changed_packages"
cat "$changed_packages"
exit 0
fi
@@ -169,5 +133,4 @@ while IFS= read -r package; do
done < "$all_packages" > "$impacted_packages"
cat "$changed_packages" >> "$impacted_packages"
LC_ALL=C sort -u "$impacted_packages" -o "$impacted_packages"
emit_selection "$impacted_packages"
LC_ALL=C sort -u "$impacted_packages"
-239
View File
@@ -129,245 +129,6 @@ 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()
+4 -33
View File
@@ -1013,12 +1013,6 @@ 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",
@@ -1143,7 +1137,6 @@ 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/')",
@@ -1151,13 +1144,13 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
"filename.startsWith('pkg/cmdutil/')",
"filename === 'skills_embed.go'",
"filename.startsWith('test/mock_mcp/')",
`name: "Test (focused: ${{ matrix.shard }})"`,
"name: Test (changed packages)",
"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-shard "$TEST_SHARD" "$TEST_BASE_REF" "$TEST_HEAD_REF"`,
"list \"$TEST_BASE_REF\" \"$TEST_HEAD_REF\"",
"needs.lint.outputs.full_suite != 'true'",
`name: "Test (race: ${{ matrix.shard }})"`,
"name: Test (workflow and release contracts)",
@@ -1217,30 +1210,8 @@ 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")
}
// 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)
}
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")
}
raceStart := focusedEnd
-20
View File
@@ -1,20 +0,0 @@
# 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.
-264
View File
@@ -1,264 +0,0 @@
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
View File
@@ -1,54 +0,0 @@
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)
}
}
}
-36
View File
@@ -1,36 +0,0 @@
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
View File
@@ -1,173 +0,0 @@
// 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
View File
@@ -1,37 +0,0 @@
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
View File
@@ -1,275 +0,0 @@
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
}
-21
View File
@@ -1,21 +0,0 @@
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
View File
@@ -1,51 +0,0 @@
// 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
View File
@@ -1,44 +0,0 @@
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]) + "..."
}
-3
View File
@@ -1,3 +0,0 @@
module gitlab.alibaba-inc.com/aes/aem-go-sdk
go 1.25.7
-97
View File
@@ -1,97 +0,0 @@
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)
}
-68
View File
@@ -1,68 +0,0 @@
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"
}