Compare commits

..

29 Commits

Author SHA1 Message Date
Chenweifeng-bd
0e691781eb feat: 修改高频异常SKILL 2026-06-13 13:13:41 +08:00
xiongyuanwen-byted
c8de4e3692 feat(sheets): implement pandas-split --sheets protocol for +table-put/+table-get/+workbook-create
Synced from sheet-skill-spec canonical (cli:table_put schema +
references). +table-put/+workbook-create accept the new shape via a
tableSheetIn -> tableSheetSpec normalize step (dtype string -> internal
type/format mapping). +table-get emits the same shape so the writer's
df_to_sheet and the reader's sheet_to_df round-trip cleanly.

isoDateToSerial now accepts the full ISO datetime form
(2024-01-15T00:00:00.000, including timezone suffixes) emitted by
df.to_json(date_format="iso"), not just yyyy-mm-dd. End-to-end verified
by the spec repo's contracts/python_helper_roundtrip script against a
real Lark spreadsheet on pandas 2.2 and 3.0.
2026-06-12 17:32:08 +08:00
xiongyuanwen-byted
a72331d007 Merge remote-tracking branch 'origin/feat/lark-sheets-develop' into feat/lark-sheets-develop 2026-06-12 12:03:00 +08:00
xiongyuanwen-byted
9950a00da4 feat(sheets): rework +workbook-create flags and --styles
- --values builds a type-less typed payload, writing through --sheets' batched set_cell_range path (raw passthrough preserves auto-detect; large tables batch; big ints via json.Number)
- drop --headers (subsumed by --values first row) and --header-style (typed header no longer auto-bold; use --styles instead)
- styles: deep-merge overlapping cell_styles/border_styles fields (was wholesale-replace which dropped fields); add manual border_styles validation (style/weight enums + sides) since --styles is on parseJSONFlagSkip and bypasses the schema validator
- regenerate flag-defs/flag-schemas/skills mirror from sheet-skill-spec (--styles flag + full per-side border schema)
2026-06-12 12:02:32 +08:00
zhengzhijiej-tech
cf3c5f13eb Merge pull request #1397 from larksuite/fix-chart-aggregate-counta-zzj
feat(sheets): add counta to chart aggregateType enum
2026-06-11 19:11:36 +08:00
zhengzhijie
b1e58d1340 feat(sheets): make --target-position and --range mutually exclusive on +pivot-create
Both flags map to the same wire field (properties.range), so passing
non-default values for both is ambiguous. Mirror the
--target-sheet-id / --target-sheet-name mutex pattern: --target-position
takes priority over --range, and supplying both with non-default values
is rejected up front with a typed FlagErrorf. --target-position=A1 is
the documented default and is treated as "not set".

Add a symmetric validateCreateInput hook on objectCRUDSpec (alongside
the existing validateUpdateInput), wire it into objectCreateInput, and
inject the pivot-specific check on pivotSpec.
2026-06-11 16:45:28 +08:00
zhengzhijie
0a17ddc45d feat(sheets): add counta to chart aggregateType enum
Add `counta` (count non-empty cells, incl. text) to manage_chart_object
dim2.series[].aggregateType in the chart flag schema. `count` only counts
numeric cells, so counting occurrences of a text/category column renders an
empty chart; `counta` enables category frequency counts. Synced from the
sheet-skill-spec canonical schema.
2026-06-11 14:32:03 +08:00
xiongyuanwen-byted
773b93cb10 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-09 19:52:08 +08:00
xiongyuanwen-byted
82a983888b fix(sheets): regenerate flag defs and fix asasalint in table io 2026-06-09 17:48:58 +08:00
xiongyuanwen-byted
9847b16d1a Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-09 17:29:26 +08:00
zhengzhijiej-tech
bed30c4ecb Merge pull request #1351 from larksuite/fix/chart-dim-insert-example
docs(sheets): chart / filter / workbook reference corrections
2026-06-09 16:47:31 +08:00
zhengzhijie
a7be567066 docs(sheets): label +sheet-create --index as 0-based
The base flag description for +sheet-create's --index omitted the
coordinate base, while its siblings +sheet-move ("Target position
(0-based)") and +sheet-copy already state 0-based. Align the description
so the index base is unambiguous. Synced from the spec source
(flag-defs.json + workbook reference).
2026-06-09 16:25:02 +08:00
zhengzhijie
e96acad2c5 docs(sheets): chart coordinate base / quoting + filter condition enums
Sync three reference-doc corrections from the spec source:

1. chart: label position.row as 0-based (first row = row:0), distinct
   from the 1-based row numbers used by A1 ranges and +dim-insert
   --position, removing the row-base ambiguity.

2. chart: convert the three runnable examples whose JSON contains a
   quoted sheet prefix ('Sheet1'!A1) from inline single-quoted
   --properties '{...}' to a stdin heredoc (--properties - <<'JSON').
   Inside an inline single-quoted string bash strips the inner quotes
   around the sheet name (and splits names with spaces into words),
   corrupting the JSON; a quoted heredoc delimiter performs no shell
   substitution and preserves it. Adds a short note on the pitfall.

3. filter / filter-view: add the full conditions[].type x compare_type
   enum table (text / number / multiValue / color and their respective
   compare_type values and values shape), and call out the
   equals/notEquals (with s) vs equal/notEqual (no s) gotcha. The docs
   previously only showed two values via examples.
2026-06-09 16:25:02 +08:00
zhengzhijie
7ac8a7d30e docs(sheets): fix invalid +dim-insert example in chart reference
The chart reference's placement example used non-existent flags
--dimension/--start/--end for +dim-insert. The real signature is
--position (required) + --count (required); copying the example
fails Validate with "--position is required". Replace it with
+dim-insert --position V --count 6 (insert 6 columns before V,
i.e. after U), aligning with the sheet-structure reference.
2026-06-09 15:34:05 +08:00
xiongyuanwen-byted
31523b7f50 docs(sheets): align +csv-put help with formula support
Sync the formula-support wording from sheet-skill-spec (flag-defs, skill
references) and update the hand-authored cobra Description and comment for
+csv-put. +csv-put evaluates a leading-= cell as a formula via
set_range_from_csv; descriptions only, no behavior change.
2026-06-08 20:38:10 +08:00
zhengzhijiej-tech
02a37029c2 Merge pull request #1296 from larksuite/feat/sheet-eval-guidance-fixes
docs(sheets): strengthen lark-sheets references for common editing pitfalls
2026-06-08 19:13:29 +08:00
zhengzhijie
556d7e3a77 docs(sheets): align write-cells reference with the generated output
Bring the hand-applied write-cells example in line with the spec-generated
reference so the CLI mirror is byte-identical to the canonical source.
2026-06-08 19:07:44 +08:00
Chenweifeng-bd
f18a082a4f docs: add lark sheets financial modeling guidance 2026-06-08 17:05:11 +08:00
zhengzhijie
b8c5176483 docs(sheets): reword guidance to avoid eval-specific phrasing
Replace scoring-framework wording in the examples with plain functional
consequences (e.g. "not delivered", "goes stale when the source changes",
"breaks the original visual format"), so the references stay agent-facing.
2026-06-08 15:44:35 +08:00
zhengzhijie
82937a0a37 docs(sheets): keep original column widths; align chart axis with requested metric
- range-operations: only widen new / overflowing columns; never recompute or
  shrink the widths of existing columns (any blanket resize, even by 1px,
  breaks the original visual format)
- chart: when the user asks for a share / percentage, the value axis should be
  a percentage (pie, or stack.percentage on bar/column) rather than raw counts
2026-06-08 14:38:00 +08:00
xiongyuanwen-byted
1cafb94a62 refactor(sheets): reuse the drive export core in +workbook-export
Replace +workbook-export's parallel export-task implementation with the shared drive ExportParams/RunExport core (pinned to type=sheet). Drops ~90 lines of duplicated poll/download code; +workbook-export now inherits drive's ctx cancellation, resume-on-timeout, filename sanitize/overwrite, and the full set of export status labels. The output contract aligns with drive's (adds ready/downloaded/doc_type; saved_path preserved). Also normalize an empty drive --output-dir to "." so drive +export behavior is unchanged, and fix the sheets export e2e to call +workbook-export instead of a nonexistent +export.
2026-06-08 12:58:11 +08:00
xiongyuanwen-byted
0b33daa136 feat(sheets): add +workbook-import wrapping the drive import core
Import a local xlsx/xls/csv as a new spreadsheet by delegating to the shared drive import flow with the target type pinned to sheet. Refactor drive +import to expose ImportParams / ValidateImport / PlanImportDryRun / RunImport (behavior unchanged, existing drive tests still cover it); sheets reuses them. Regenerate flag_defs_gen.go and sync the spec mirror.
2026-06-08 11:00:46 +08:00
xiongyuanwen-byted
5a61b97ac3 docs(sheets): sync SKILL.md (drop "Feishu sheets only" caveat)
Mirror the upstream sheet-skill-spec change removing the "applies to Feishu sheets only" tail from the 14 sheet reference descriptions.
2026-06-07 22:45:53 +08:00
xiongyuanwen-byted
e01f2dfdd5 docs(sheets): sync SKILL.md (drop "not for local Excel" caveat)
Mirror the upstream sheet-skill-spec change removing the "not applicable to local Excel files" tail from the sheets skill and reference descriptions.
2026-06-07 22:39:58 +08:00
xiongyuanwen-byted
45f807459e docs(sheets): surface typed-write path at the write-decision point
Quick-ref table (SKILL.md, the first decision point) had no +table-put and
gated typed writes on "DataFrame", so a model holding a Counter/list/dict
would fall back to +csv-put and silently lose number/date fidelity.

- split csv-put row to plain-text values (no numeric/date semantics)
- add +table-put row for typed writes into an existing sheet
- add +workbook-create --sheets row for create + typed write in one shot
- add judgment note: number/amount/date/percent/count -> +table-put
  (or +workbook-create --sheets when the workbook does not exist yet);
  plain text -> +csv-put
- reframe write-cells scenario row to lead with numeric semantics
- point new-table writes at +workbook-create --sheets (one shot) instead
  of the create-empty-then-table-put two-step

Synced from sheet-skill-spec canonical (generate:cli + sync:cli).
2026-06-07 00:30:13 +08:00
xiongyuanwen-byted
8906e87fb1 feat(sheets): implement table-put/table-get and sync skill specs
- Add lark_sheet_table_io.go with +table-put / +table-get and tests
- Refactor read-data; extend workbook; register new shortcuts
- Sync generated flag defs/schemas (go:embed) from sheet-skill-spec
- Sync skill references (write-cells numeric-column guidance, plus
  read-data / workbook / chart updates)
2026-06-05 20:03:33 +08:00
zhengzhijie
d5a53d921d docs(sheets): strengthen lark-sheets references for common editing pitfalls
Add targeted guidance to six lark-sheets references to reduce frequent
mistakes when editing spreadsheets through the CLI:

- write-cells: sanity-check units / dimension conversion / quantity factors
  before formula writes (formulas can run clean yet be off by a factor);
  keep derived output off original data columns to avoid clobbering source
- core-operations: prefer live formulas for derived values even when "live
  update" is not explicitly requested; scope rewrite/transform precisely so
  rows/columns that should stay unchanged are kept 1:1; treat header-stated
  format rules as checklist items; confirm the artifact file actually exists
  before finishing; write back bare values from local scripts
- visual-standards: apply border/header formatting on explicit request and
  identify the real header row; keep font size consistent with the source
- range-operations: keep total column width within A4 for printing
- read-data: dedup/compare long numbers via raw values, not csv formatted
  display (scientific notation collapses distinct numbers and causes false
  duplicates)
- chart: format date/number axes via source-cell number_format; place charts
  outside the data area so they do not cover existing data
2026-06-05 19:20:25 +08:00
zhengzhijiej-tech
0ff7f0407e Merge pull request #1264 from zhengzhijiej-tech/feat/sheet-gridline
feat(sheets): add gridline show/hide shortcuts
2026-06-04 19:12:41 +08:00
zhengzhijie
6e067f2180 feat(sheets): add +sheet-show-gridline / +sheet-hide-gridline shortcuts 2026-06-04 17:00:07 +08:00
310 changed files with 8044 additions and 30295 deletions

View File

@@ -73,20 +73,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/)
text: errs-no-legacy-helper
linters:
- forbidigo

View File

@@ -2,46 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.51] - 2026-06-10
### Features
- **apps**: Support multi dev modes (#1175)
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
- **base**: Configure initial base table schema (#1377)
- **vc**: Add recording event support (#1369)
- **minutes**: Replace words for transcript (#1372)
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
- **slides**: Emit typed error envelopes across the slides domain (#1349)
### Documentation
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
## [v1.0.50] - 2026-06-09
### Features
- **doc**: Emit typed error envelopes across the doc domain (#1346)
- **event**: Emit typed error envelopes across the event domain (#1289)
- **contact**: Emit typed error envelopes across the contact domain (#1287)
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
- **cli**: Adjust agent timeout hint output conditions (#1328)
### Bug Fixes
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
- **slides**: Build create URL locally instead of drive metas call (#1329)
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
### Documentation
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
- **doc**: Document `<folder-manager>` resource block (#1168)
- **drive**: Add drive comment location guidance (#1258)
## [v1.0.49] - 2026-06-08
### Features
@@ -1106,8 +1066,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47

View File

@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
## Installation & Quick Start

View File

@@ -41,7 +41,7 @@
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
| 🔗 应用 | 创建妙搭Spark/Miaoda应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
## 安装与快速开始

View File

@@ -1,84 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1.
type VCRecordingEndedOutput struct {
Type string `json:"type" desc:"Event type; always vc.recording.recording_ended_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
EventTime string `json:"event_time,omitempty" desc:"Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone"`
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
}
type recordingEndedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingEndedEvent `json:"event"`
}
type recordingEndedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
}
func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingEndedEnvelope(raw)
if !ok {
return raw.Payload, nil
}
if !isRecordingEndedBeanEvent(envelope) {
return nil, nil
}
out := &VCRecordingEndedOutput{
Type: recordingEndedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingEndedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
}
return json.Marshal(out)
}
func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) {
var envelope recordingEndedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingEndedEventTime(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -1,84 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1.
type VCRecordingStartedOutput struct {
Type string `json:"type" desc:"Event type; always vc.recording.recording_started_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
EventTime string `json:"event_time,omitempty" desc:"Recording start time in RFC3339 / ISO 8601 with the current system timezone"`
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
}
type recordingStartedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingStartedEvent `json:"event"`
}
type recordingStartedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
}
func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingStartedEnvelope(raw)
if !ok {
return raw.Payload, nil
}
if !isRecordingStartedBeanEvent(envelope) {
return nil, nil
}
out := &VCRecordingStartedOutput{
Type: recordingStartedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingStartedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
}
return json.Marshal(out)
}
func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) {
var envelope recordingStartedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingStartedEventTime(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -1,468 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_RecordingEventsRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
}{
{eventTypeRecordingStarted},
{eventTypeRecordingTranscriptGenerated},
{eventTypeRecordingEnded},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:recording:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
}
if !strings.Contains(def.Description, "recording_bean") {
t.Errorf("Description should document recording_bean source, got %q", def.Description)
}
if !strings.Contains(def.Description, "connected to Feishu software") {
t.Errorf("Description should document Feishu software connection requirement, got %q", def.Description)
}
if strings.Contains(def.Description, "future") || strings.Contains(def.Description, "software_recording") {
t.Errorf("Description should not mention future sources, got %q", def.Description)
}
if tc.eventType == eventTypeRecordingEnded && (strings.Contains(def.Description, "object_type") || strings.Contains(def.Description, "object_id")) {
t.Errorf("ended Description should not document object metadata, got %q", def.Description)
}
wantSchemaType := reflect.TypeOf(VCRecordingStartedOutput{})
switch tc.eventType {
case eventTypeRecordingTranscriptGenerated:
wantSchemaType = reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})
case eventTypeRecordingEnded:
wantSchemaType = reflect.TypeOf(VCRecordingEndedOutput{})
}
if def.Schema.Custom.Type != wantSchemaType {
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, wantSchemaType)
}
})
}
}
func TestProcessVCRecordingStarted(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
out := runRecordingProcess[VCRecordingStartedOutput](t, eventTypeRecordingStarted, processVCRecordingStarted, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_start_001",
"event_type": "vc.recording.recording_started_v1",
"create_time": "1761782400000"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean"
}
}`)
if out.Type != eventTypeRecordingStarted {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_rec_start_001" || out.EventTime != recordingTestEventTime(1761782400000) {
t.Errorf("EventID/EventTime = %q/%q", out.EventID, out.EventTime)
}
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
}
}
func TestProcessVCRecordingTranscriptGenerated(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got := runRecordingProcessRaw(t, eventTypeRecordingTranscriptGenerated, processVCRecordingTranscriptGenerated, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_transcript_001",
"event_type": "vc.recording.recording_transcript_generated_v1",
"create_time": "1761782400100"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean",
"transcript_items": [
{
"speaker": {
"id": {
"open_id": "ou_0f8bf7acdf2ae69553ecbdbfbbd10a53",
"union_id": "on_bc03f16d781bff4178a5d11e48eb1867",
"user_id": null
},
"user_type": 100,
"user_role": 1,
"user_name": "Alice"
},
"text": "hello world",
"language": "en_us",
"start_time_ms": "1761782399000",
"end_time_ms": "1761782400000",
"sentence_id": "987654321"
},
{
"speaker": {
"user_name": "Bob"
},
"text": "second sentence",
"language": "en_us",
"start_time_ms": "1761782401000",
"end_time_ms": "1761782402000",
"sentence_id": "987654322"
}
]
}
}`)
if got == nil {
t.Fatal("Process output is nil")
}
var out VCRecordingTranscriptGeneratedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
}
if out.Type != eventTypeRecordingTranscriptGenerated {
t.Errorf("Type = %q", out.Type)
}
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
}
if out.EventTime != recordingTestEventTime(1761782400100) {
t.Errorf("EventTime = %q", out.EventTime)
}
if len(out.TranscriptItems) != 2 {
t.Fatalf("TranscriptItems len = %d, want 2", len(out.TranscriptItems))
}
item := out.TranscriptItems[0]
if item.SpeakerName != "Alice" || item.Text != "hello world" {
t.Errorf("Transcript speaker/text = %q/%q", item.SpeakerName, item.Text)
}
if item.StartTime != recordingTestEventTime(1761782399000) || item.EndTime != recordingTestEventTime(1761782400000) {
t.Errorf("Transcript timing = %q/%q", item.StartTime, item.EndTime)
}
if item.SentenceID != "987654321" {
t.Errorf("SentenceID = %q, want 987654321", item.SentenceID)
}
if out.TranscriptItems[1].SpeakerName != "Bob" || out.TranscriptItems[1].SentenceID != "987654322" {
t.Errorf("second transcript item = %+v", out.TranscriptItems[1])
}
itemJSON, err := json.Marshal(item)
if err != nil {
t.Fatalf("marshal transcript item: %v", err)
}
var itemFields map[string]any
if err := json.Unmarshal(itemJSON, &itemFields); err != nil {
t.Fatalf("unmarshal transcript item JSON: %v", err)
}
wantItemFields := map[string]bool{
"speaker_name": true,
"text": true,
"start_time": true,
"end_time": true,
"sentence_id": true,
}
for gotField := range itemFields {
if !wantItemFields[gotField] {
t.Errorf("Transcript item should not contain field %q, got %s", gotField, string(itemJSON))
}
}
for wantField := range wantItemFields {
if _, ok := itemFields[wantField]; !ok {
t.Errorf("Transcript item missing field %q, got %s", wantField, string(itemJSON))
}
}
for _, unexpected := range []string{
`"seq_id"`,
`"speaker"`,
`"user_open_id"`,
`"user_type"`,
`"user_role"`,
`"language"`,
`"start_time_ms"`,
`"end_time_ms"`,
`"sequence_id"`,
`"transcript_item"`,
} {
if strings.Contains(string(got), unexpected) {
t.Errorf("Transcript output should not contain %s, got %s", unexpected, string(got))
}
}
if !strings.Contains(string(got), `"sentence_id":"987654321"`) {
t.Errorf("Transcript output should contain sentence_id, got %s", string(got))
}
}
func TestProcessVCRecordingEnded(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
out := runRecordingProcess[VCRecordingEndedOutput](t, eventTypeRecordingEnded, processVCRecordingEnded, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_end_001",
"event_type": "vc.recording.recording_ended_v1",
"create_time": "1761782400200"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean",
"object_type": "minutes",
"object_id": "minute_token_001"
}
}`)
if out.Type != eventTypeRecordingEnded {
t.Errorf("Type = %q", out.Type)
}
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
}
if out.EventTime != recordingTestEventTime(1761782400200) {
t.Errorf("EventTime = %q", out.EventTime)
}
}
func TestProcessVCRecordingEnded_DropsObjectMetadata(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got := runRecordingProcessRaw(t, eventTypeRecordingEnded, processVCRecordingEnded, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_end_001",
"event_type": "vc.recording.recording_ended_v1",
"create_time": "1761782400200"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean",
"object_type": "minutes",
"object_id": "minute_token_001"
}
}`)
if strings.Contains(string(got), "object_type") || strings.Contains(string(got), "object_id") {
t.Fatalf("ended output should drop object metadata, got %s", string(got))
}
}
func TestProcessVCRecording_DropsTimestampField(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got := runRecordingProcessRaw(t, eventTypeRecordingStarted, processVCRecordingStarted, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_start_001",
"event_type": "vc.recording.recording_started_v1",
"create_time": "1761782400000"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean"
}
}`)
if strings.Contains(string(got), `"timestamp"`) {
t.Fatalf("recording output should use event_time instead of timestamp, got %s", string(got))
}
if !strings.Contains(string(got), `"event_time":"`+recordingTestEventTime(1761782400000)+`"`) {
t.Fatalf("recording output should include ISO 8601 event_time, got %s", string(got))
}
}
func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
payload string
}{
{
name: "started",
eventType: eventTypeRecordingStarted,
process: processVCRecordingStarted,
payload: `{
"schema": "2.0",
"header": {"event_id": "ev_rec_start_001", "event_type": "vc.recording.recording_started_v1"},
"event": {"unique_key": "recording_001", "source": "software_recording"}
}`,
},
{
name: "transcript",
eventType: eventTypeRecordingTranscriptGenerated,
process: processVCRecordingTranscriptGenerated,
payload: `{
"schema": "2.0",
"header": {"event_id": "ev_rec_transcript_001", "event_type": "vc.recording.recording_transcript_generated_v1"},
"event": {"unique_key": "recording_001", "source": "software_recording", "transcript_items": []}
}`,
},
{
name: "ended",
eventType: eventTypeRecordingEnded,
process: processVCRecordingEnded,
payload: `{
"schema": "2.0",
"header": {"event_id": "ev_rec_end_001", "event_type": "vc.recording.recording_ended_v1"},
"event": {"unique_key": "recording_001", "source": "software_recording"}
}`,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := runRecordingProcessRaw(t, tc.eventType, tc.process, tc.payload)
if got != nil {
t.Fatalf("non-recording_bean event should be filtered, got %s", string(got))
}
})
}
}
func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{name: "started", eventType: eventTypeRecordingStarted, process: processVCRecordingStarted},
{name: "transcript", eventType: eventTypeRecordingTranscriptGenerated, process: processVCRecordingTranscriptGenerated},
{name: "ended", eventType: eventTypeRecordingEnded, process: processVCRecordingEnded},
} {
t.Run(tc.name, func(t *testing.T) {
raw := &event.RawEvent{
EventType: tc.eventType,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
})
}
}
func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
}{
{eventTypeRecordingStarted},
{eventTypeRecordingTranscriptGenerated},
{eventTypeRecordingEnded},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathRecordingSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, tc.eventType)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathRecordingUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, tc.eventType)
})
}
}
func runRecordingProcess[T any](t *testing.T, eventType string, process event.ProcessFunc, payload string) T {
t.Helper()
got := runRecordingProcessRaw(t, eventType, process, payload)
if got == nil {
t.Fatal("Process output is nil")
}
var out T
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
}
return out
}
func runRecordingProcessRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
t.Helper()
raw := &event.RawEvent{
EventType: eventType,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
return got
}
func recordingTestEventTime(millis int64) string {
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -1,163 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events.
type VCRecordingTranscriptItemOutput struct {
SpeakerName string `json:"speaker_name,omitempty" desc:"Speaker display name"`
Text string `json:"text,omitempty" desc:"Transcript text"`
StartTime string `json:"start_time,omitempty" desc:"Transcript item start time in RFC3339 / ISO 8601 with the current system timezone"`
EndTime string `json:"end_time,omitempty" desc:"Transcript item end time in RFC3339 / ISO 8601 with the current system timezone"`
SentenceID string `json:"sentence_id,omitempty" desc:"Transcript sentence ID"`
}
// VCRecordingTranscriptGeneratedOutput is the flattened shape for vc.recording.recording_transcript_generated_v1.
type VCRecordingTranscriptGeneratedOutput struct {
Type string `json:"type" desc:"Event type; always vc.recording.recording_transcript_generated_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
EventTime string `json:"event_time,omitempty" desc:"Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone"`
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"`
}
type recordingTranscriptGeneratedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingTranscriptGeneratedEvent `json:"event"`
}
type recordingTranscriptGeneratedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
TranscriptItems []recordingTranscriptGeneratedItemIn `json:"transcript_items"`
}
type recordingTranscriptGeneratedItemIn struct {
Speaker *recordingTranscriptGeneratedSpeakerIn `json:"speaker"`
Text string `json:"text"`
StartTimeMs recordingTranscriptGeneratedString `json:"start_time_ms"`
EndTimeMs recordingTranscriptGeneratedString `json:"end_time_ms"`
SentenceID string `json:"sentence_id"`
}
type recordingTranscriptGeneratedSpeakerIn struct {
UserName string `json:"user_name"`
}
type recordingTranscriptGeneratedString string
func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw)
if !ok {
return raw.Payload, nil
}
if !isRecordingTranscriptGeneratedBeanEvent(envelope) {
return nil, nil
}
out := &VCRecordingTranscriptGeneratedOutput{
Type: recordingTranscriptGeneratedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems),
}
return json.Marshal(out)
}
func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) {
var envelope recordingTranscriptGeneratedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingTranscriptGeneratedEventTime(raw string) string {
return recordingTranscriptGeneratedMillisToLocalRFC3339(raw)
}
func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}
func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput {
if len(items) == 0 {
return nil
}
out := make([]VCRecordingTranscriptItemOutput, 0, len(items))
for _, item := range items {
out = append(out, recordingTranscriptItem(item))
}
return out
}
func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordingTranscriptItemOutput {
return VCRecordingTranscriptItemOutput{
SpeakerName: recordingSpeakerName(item.Speaker),
Text: item.Text,
StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()),
EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()),
SentenceID: item.SentenceID,
}
}
func recordingSpeakerName(speaker *recordingTranscriptGeneratedSpeakerIn) string {
if speaker == nil {
return ""
}
return speaker.UserName
}
func (s *recordingTranscriptGeneratedString) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
var str string
if err := json.Unmarshal(data, &str); err == nil {
*s = recordingTranscriptGeneratedString(str)
return nil
}
var num json.Number
if err := json.Unmarshal(data, &num); err != nil {
return err
}
*s = recordingTranscriptGeneratedString(num.String())
return nil
}
func (s recordingTranscriptGeneratedString) String() string {
return string(s)
}

View File

@@ -11,18 +11,13 @@ import (
)
const (
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1"
eventTypeRecordingEnded = "vc.recording.recording_ended_v1"
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
pathRecordingSubscribe = "/open-apis/vc/v1/recordings/subscription"
pathRecordingUnsubscribe = "/open-apis/vc/v1/recordings/unsubscription"
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
)
@@ -62,53 +57,5 @@ func Keys() []event.KeyDefinition {
},
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
},
{
Key: eventTypeRecordingStarted,
DisplayName: "Recording started",
Description: "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.",
EventType: eventTypeRecordingStarted,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})},
},
Process: processVCRecordingStarted,
PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeRecordingStarted},
},
{
Key: eventTypeRecordingTranscriptGenerated,
DisplayName: "Recording transcript generated",
Description: "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.",
EventType: eventTypeRecordingTranscriptGenerated,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})},
},
Process: processVCRecordingTranscriptGenerated,
PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeRecordingTranscriptGenerated},
},
{
Key: eventTypeRecordingEnded,
DisplayName: "Recording ended",
Description: "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.",
EventType: eventTypeRecordingEnded,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})},
},
Process: processVCRecordingEnded,
PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeRecordingEnded},
},
}
}

View File

@@ -18,26 +18,17 @@ type IOStreams struct {
Out io.Writer
ErrOut io.Writer
IsTerminal bool
// ErrIsTerminal reports whether ErrOut is an interactive terminal. Use it to
// gate stderr-only animations (spinners) so pipes / CI / captured stderr stay
// clean. Derived from ErrOut's underlying *os.File; non-file writers → false.
ErrIsTerminal bool
}
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
// IsTerminal is derived from in's underlying *os.File, if any; non-file
// readers (bytes.Buffer, strings.Reader, …) yield IsTerminal=false.
// ErrIsTerminal is derived the same way from errOut.
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
}
errIsTerminal := false
if f, ok := errOut.(*os.File); ok {
errIsTerminal = term.IsTerminal(int(f.Fd()))
}
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, ErrIsTerminal: errIsTerminal}
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
@@ -66,7 +57,6 @@ func normalizeStreams(s *IOStreams) *IOStreams {
}
if out.ErrOut == nil {
out.ErrOut = sys.ErrOut
out.ErrIsTerminal = sys.ErrIsTerminal
}
}
return &out

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"fmt"
"io"
"sync"
"time"
)
// spinnerFrames are braille spinner glyphs cycled to animate progress.
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
const (
spinnerInterval = 80 * time.Millisecond
spinnerHideCursor = "\x1b[?25l"
spinnerShowCursor = "\x1b[?25h"
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
)
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
// until the returned stop() is called, e.g.:
//
// ⠹ Publishing dev → main... 3s
//
// It is meant for slow operations (long polls, first-time provisioning) so the
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
// animation never pollutes stdout — the JSON/pretty result stays clean.
//
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
// the stderr-TTY check (IOStreams.ErrIsTerminal), not the output format: the
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
//
// stop() clears the spinner line, restores the cursor, and blocks until the
// render goroutine has finished — so callers can safely write the result to
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
// it is safe to call more than once (e.g. an explicit call plus a defer).
func StartSpinner(w io.Writer, enabled bool, label string) func() {
if !enabled || w == nil {
return func() {}
}
done := make(chan struct{})
finished := make(chan struct{})
start := time.Now()
go func() {
defer close(finished)
frame := 0
fmt.Fprint(w, spinnerHideCursor)
render := func() {
elapsed := int(time.Since(start).Seconds())
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
frame = (frame + 1) % len(spinnerFrames)
}
render()
ticker := time.NewTicker(spinnerInterval)
defer ticker.Stop()
for {
select {
case <-done:
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
return
case <-ticker.C:
render()
}
}
}()
var once sync.Once
return func() {
once.Do(func() {
close(done)
<-finished // wait for the line to be cleared before returning
})
}
}

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"strings"
"testing"
)
// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, false, "working")
stop()
stop() // idempotent
if buf.Len() != 0 {
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
}
}
// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
stop := StartSpinner(nil, true, "working")
stop() // must not panic
}
// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, true, "Publishing")
// The goroutine renders the first frame synchronously before selecting on
// the stop channel, so even an immediate stop() yields one full cycle.
stop()
stop() // idempotent, must not panic or double-write after finished
out := buf.String()
if !strings.Contains(out, spinnerHideCursor) {
t.Errorf("missing hide-cursor escape:\n%q", out)
}
if !strings.Contains(out, spinnerFrames[0]) {
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
}
if !strings.Contains(out, "Publishing...") {
t.Errorf("missing label:\n%q", out)
}
if !strings.Contains(out, spinnerClearLine) {
t.Errorf("missing clear-line escape:\n%q", out)
}
if !strings.HasSuffix(out, spinnerShowCursor) {
t.Errorf("must end by restoring the cursor:\n%q", out)
}
}

View File

@@ -21,19 +21,14 @@ var migratedCommonHelperPaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/event/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/wiki/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

View File

@@ -22,19 +22,14 @@ var migratedEnvelopePaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/event/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/wiki/",
"shortcuts/im/",
}

View File

@@ -620,7 +620,6 @@ func boom() error {
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
for _, path := range []string{
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/okr/okr_image_upload.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_update.go",
@@ -951,16 +950,11 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
"HandleApiResult",
}
paths := []string{
"shortcuts/doc/docs_fetch_v2.go",
"shortcuts/drive/drive_search.go",
"shortcuts/mail/mail_send.go",
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/okr/okr_progress_create.go",
"shortcuts/sheets/helpers.go",
"shortcuts/slides/slides_create.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_query.go",
"shortcuts/wiki/wiki_node_get.go",
}
for _, path := range paths {
for _, helper := range helpers {
@@ -1009,91 +1003,6 @@ func boom() {
}
}
func TestCheckNoLegacyCommonHelperCall_CoversDocPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/doc/docs_fetch_v2.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_CoversSheetsPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/sheets/helpers.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on sheets path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_CoversSlidesPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/slides/slides_create.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on slides path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_CoversMarkdownPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/markdown/markdown_fetch.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on markdown path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_CoversWikiPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/wiki/wiki_node_get.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on wiki path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package contact

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.51",
"version": "1.0.49",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -21,12 +21,9 @@ var AppsAccessScopeGet = common.Shortcut{
Command: "+access-scope-get",
Description: "Get Miaoda app access scope configuration",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +access-scope-get --app-id <app_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
},
@@ -45,9 +42,9 @@ var AppsAccessScopeGet = common.Shortcut{
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("GET", path, nil, nil)
data, err := rctx.CallAPI("GET", path, nil, nil)
if err != nil {
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
return err
}
// 原样透传 — 保留服务端字符串枚举 (All/Tenant/Range),不合并 users/departments/chats。
rctx.OutFormat(data, nil, func(w io.Writer) {

View File

@@ -27,14 +27,9 @@ var AppsAccessScopeSet = common.Shortcut{
Command: "+access-scope-set",
Description: "Set Miaoda app access scope (specific / public / tenant)",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope tenant`,
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope public --require-login`,
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope specific --targets '[{"type":"user","id":"<open_id>"}]'`,
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "scope", Desc: "scope: specific | public | tenant", Required: true, Enum: []string{"specific", "public", "tenant"}},
@@ -69,9 +64,9 @@ var AppsAccessScopeSet = common.Shortcut{
}
appID := strings.TrimSpace(rctx.Str("app-id"))
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("PUT", path, nil, body)
data, err := rctx.CallAPI("PUT", path, nil, body)
if err != nil {
return withAppsHint(err, "verify --app-id is correct; for scope=specific, each --targets id must be a valid open_id/department_id/chat_id and --approver a valid open_id; review the current scope with `lark-cli apps +access-scope-get --app-id <app_id>`")
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "access-scope set: %s\n", rctx.Str("scope"))

View File

@@ -8,62 +8,9 @@ import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func testRuntimeAccessScope(t *testing.T, scope, targets, approver string, applyEnabled, requireLogin bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "access-scope-set"}
cmd.Flags().String("scope", scope, "")
cmd.Flags().String("targets", targets, "")
cmd.Flags().String("approver", approver, "")
cmd.Flags().Bool("apply-enabled", applyEnabled, "")
cmd.Flags().Bool("require-login", requireLogin, "")
return common.TestNewRuntimeContext(cmd, nil)
}
func TestBuildAccessScopeBody_Branches(t *testing.T) {
t.Run("invalid scope", func(t *testing.T) {
if _, err := buildAccessScopeBody(testRuntimeAccessScope(t, "bogus", "", "", false, false)); err == nil {
t.Error("unknown scope must error")
}
})
t.Run("specific with all target kinds and approver", func(t *testing.T) {
body, err := buildAccessScopeBody(testRuntimeAccessScope(t,
"specific",
`[{"type":"user","id":"u1"},{"type":"department","id":"d1"},{"type":"chat","id":"c1"}]`,
"ou_appr", true, false))
if err != nil {
t.Fatalf("err=%v", err)
}
if body["scope"] != "Range" {
t.Errorf("scope=%v want Range", body["scope"])
}
for _, k := range []string{"users", "departments", "chats", "apply_config"} {
if _, ok := body[k]; !ok {
t.Errorf("missing %q in body=%v", k, body)
}
}
})
t.Run("specific with invalid targets JSON", func(t *testing.T) {
if _, err := buildAccessScopeBody(testRuntimeAccessScope(t, "specific", "{bad", "", false, false)); err == nil {
t.Error("invalid targets JSON must error")
}
})
t.Run("public sets require_login", func(t *testing.T) {
body, err := buildAccessScopeBody(testRuntimeAccessScope(t, "public", "", "", false, true))
if err != nil {
t.Fatalf("err=%v", err)
}
if body["scope"] != "All" || body["require_login"] != true {
t.Errorf("public body=%v", body)
}
})
}
func TestAppsAccessScopeSet_Specific(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
@@ -254,44 +201,3 @@ func TestAppsAccessScopeSet_TrimsAppIDInPath(t *testing.T) {
t.Fatalf("execute err=%v", err)
}
}
func TestSplitAccessScopeTargets_Partitions(t *testing.T) {
users, departments, chats := splitAccessScopeTargets([]map[string]interface{}{
{"type": "user", "id": "u1"},
{"type": "department", "id": "d1"},
{"type": "chat", "id": "c1"},
{"type": "user", "id": " "}, // empty id skipped
{"type": "unknown", "id": "x"}, // unknown type skipped
})
if len(users) != 1 || users[0] != "u1" {
t.Errorf("users=%v want [u1]", users)
}
if len(departments) != 1 || departments[0] != "d1" {
t.Errorf("departments=%v want [d1]", departments)
}
if len(chats) != 1 || chats[0] != "c1" {
t.Errorf("chats=%v want [c1]", chats)
}
}
func TestValidateTargetsJSON_Cases(t *testing.T) {
cases := []struct {
name string
in string
wantErr bool
}{
{"invalid json", "{not json", true},
{"empty array", "[]", true},
{"bad type", `[{"type":"role","id":"r1"}]`, true},
{"empty id", `[{"type":"user","id":" "}]`, true},
{"valid", `[{"type":"user","id":"u1"}]`, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := validateTargetsJSON(c.in)
if (err != nil) != c.wantErr {
t.Errorf("validateTargetsJSON(%q) err=%v wantErr=%v", c.in, err, c.wantErr)
}
})
}
}

View File

@@ -1,71 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// TestAppsList_503IsRetryableTypedError pins the typed-error upgrade: a 5xx
// response from the apps list endpoint must surface as a typed errs.Problem with
// Retryable == true (via CallAPITyped → httpStatusError). The pre-migration
// CallAPI path produced a legacy *output.ExitError with no Retryable field, so
// this test fails until AppsList is migrated to CallAPITyped.
func TestAppsList_503IsRetryableTypedError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps",
Status: 503,
// A gateway-style non-JSON body (text/html) forces the status-based
// classifier (httpStatusError) rather than the API-envelope path.
Headers: http.Header{"Content-Type": []string{"text/html"}},
RawBody: []byte("<html><body>503 Service Unavailable</body></html>"),
})
err := runAppsShortcut(t, AppsList,
[]string{"+list", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected an error on 503, got nil; stdout:\n%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.Problem on 503, got %T: %v", err, err)
}
if !p.Retryable {
t.Fatalf("expected Retryable == true on 503, got Problem=%+v", p)
}
}
// TestAppsList_SuccessShapeUnchanged pins that the success path is
// output-shape-neutral after migration: a 200 envelope still yields a success
// stdout envelope carrying the app_id.
func TestAppsList_SuccessShapeUnchanged(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"app_id": "a", "name": "n"},
},
},
},
})
if err := runAppsShortcut(t, AppsList,
[]string{"+list", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"app_id": "a"`) {
t.Fatalf("stdout missing app_id: %s", got)
}
}

View File

@@ -1,83 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsChat sends a user message to a session, starting/continuing a conversation.
// Async: the message is queued and the response carries no business payload (no
// turn_id, no next_poll_after_ms — the turn is not generated yet). Poll
// +session-get; it returns next_poll_after_ms, and once the turn runs its handle
// is in latest_turn.turn_id.
// Turn cost varies sharply by init state: the first +chat on a not-initialized
// app runs a one-time design + first-generation pass server-side (~20-50 min);
// chat on an already-initialized app is incremental and finishes in minutes.
// The init-state check and matching polling cadence live in the lark-apps
// skill reference (references/lark-apps-cloud-dev.md) — the canonical source.
var AppsChat = common.Shortcut{
Service: appsService,
Command: "+chat",
Description: "Send a message to a session to start/continue a conversation",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message "做一个待办清单页面"`,
`Example: lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message "把首页标题改为 我的待办"`,
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "session-id", Desc: "session ID", Required: true},
{Name: "message", Desc: "user message text", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
if strings.TrimSpace(rctx.Str("session-id")) == "" {
return output.ErrValidation("--session-id is required")
}
// Do not echo --message content in the error (spec §4 redaction).
if strings.TrimSpace(rctx.Str("message")) == "" {
return output.ErrValidation("--message is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(chatPath(rctx.Str("app-id"), rctx.Str("session-id"))).
Desc("Send a message to a session").
Body(buildChatBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("POST", chatPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, buildChatBody(rctx))
if err != nil {
return withAppsHint(err, "if the session_id is unknown or invalid, list this app's sessions with `lark-cli apps +session-list --app-id "+strings.TrimSpace(rctx.Str("app-id"))+"`")
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "message sent; poll +session-get for turn status\n")
})
return nil
},
}
func chatPath(appID, sessionID string) string {
return sessionPath(appID, sessionID) + "/chat"
}
func buildChatBody(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"message": strings.TrimSpace(rctx.Str("message")),
}
}

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsChat_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat",
Body: map[string]interface{}{
"code": 0,
// +chat is async and returns NO business payload (no turn_id, no
// next_poll_after_ms — the turn is not generated yet). turn_id and the
// poll interval are read later from +session-get.
"data": map[string]interface{}{},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsChat,
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "把首页表头改成蓝色", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["message"] != "把首页表头改成蓝色" {
t.Fatalf("body.message = %v", sent["message"])
}
if _, present := sent["attachment_ids"]; present {
t.Fatalf("attachment_ids must not be sent this iteration: %v", sent)
}
// +chat carries no next_poll_after_ms; the CLI must not fabricate one.
if got := stdout.String(); strings.Contains(got, "next_poll_after_ms") {
t.Fatalf("stdout must not reference next_poll_after_ms (chat returns none): %s", got)
}
}
func TestAppsChat_Pretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
if err := runAppsShortcut(t, AppsChat,
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "hi", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "message sent") || !strings.Contains(got, "+session-get") {
t.Fatalf("pretty wrong: %q", got)
}
}
func TestAppsChat_RequiresMessage(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsChat,
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "message") {
t.Fatalf("expected --message required error, got %v", err)
}
}
// Security: a non-blank message that fails for another reason must never be echoed.
// Here we assert the blank-message error names the field only (no content leak path).
func TestAppsChat_ValidationDoesNotEchoMessage(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
// blank message triggers validation; the error must mention the flag, not any content.
err := runAppsShortcut(t, AppsChat,
[]string{"+chat", "--app-id", "", "--session-id", "conv_x", "--message", "secret-content-xyz", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected validation error")
}
if strings.Contains(err.Error(), "secret-content-xyz") {
t.Fatalf("validation error must not echo --message content: %v", err)
}
}
func TestAppsChat_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsChat,
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "hi", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, `"message": "hi"`) {
t.Fatalf("dry-run missing message body: %s", got)
}
}

View File

@@ -13,24 +13,18 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create Miaoda apps"
// AppsCreate creates a new Miaoda app.
var AppsCreate = common.Shortcut{
Service: appsService,
Command: "+create",
Description: "Create a new Miaoda app",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +create --name "审批系统" --app-type full_stack`,
`Example: lark-cli apps +create --name "活动页" --app-type html --description "活动报名"`,
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "name", Desc: "app display name", Required: true},
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}},
{Name: "app-type", Desc: "app type (currently only: HTML)", Required: true},
{Name: "description", Desc: "app description"},
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
},
@@ -38,6 +32,13 @@ var AppsCreate = common.Shortcut{
if strings.TrimSpace(rctx.Str("name")) == "" {
return output.ErrValidation("--name is required")
}
appType := strings.TrimSpace(rctx.Str("app-type"))
if appType == "" {
return output.ErrValidation("--app-type is required")
}
if !validAppTypes[appType] {
return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML)", appType))
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
@@ -47,9 +48,9 @@ var AppsCreate = common.Shortcut{
Body(buildAppsCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
data, err := rctx.CallAPI("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
if err != nil {
return withAppsHint(err, createHint)
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "created: %s\n", common.GetString(data, "app", "app_id"))
@@ -58,13 +59,15 @@ var AppsCreate = common.Shortcut{
},
}
// 应用类型枚举。当前只有 HTML未来会扩展SPA、NATIVE、...)。
var validAppTypes = map[string]bool{
"HTML": true,
}
func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
// --app-type is constrained to the lowercase enum (html / full_stack) by the
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
// a server concern and is intentionally not surfaced by the CLI.
body := map[string]interface{}{
"name": strings.TrimSpace(rctx.Str("name")),
"app_type": rctx.Str("app-type"),
"app_type": strings.TrimSpace(rctx.Str("app-type")),
}
if desc := strings.TrimSpace(rctx.Str("description")); desc != "" {
body["description"] = desc

View File

@@ -22,7 +22,6 @@ import (
func newAppsExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
@@ -69,7 +68,7 @@ func TestAppsCreate_Success(t *testing.T) {
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--description", "d", "--as", "user"},
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--description", "d", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
@@ -84,8 +83,8 @@ func TestAppsCreate_Success(t *testing.T) {
if sent["name"] != "Demo" {
t.Fatalf("body.name = %v", sent["name"])
}
if sent["app_type"] != "html" {
t.Fatalf("body.app_type = %v (want html)", sent["app_type"])
if sent["app_type"] != "HTML" {
t.Fatalf("body.app_type = %v (want HTML)", sent["app_type"])
}
if sent["description"] != "d" {
t.Fatalf("body.description = %v", sent["description"])
@@ -109,7 +108,7 @@ func TestAppsCreate_WithIconURL(t *testing.T) {
})
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
@@ -134,7 +133,7 @@ func TestAppsCreate_PrettyOutputReadsNestedAppID(t *testing.T) {
})
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--format", "pretty", "--as", "user"},
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
@@ -145,7 +144,7 @@ func TestAppsCreate_PrettyOutputReadsNestedAppID(t *testing.T) {
func TestAppsCreate_RequiresName(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "html", "--as", "user"}, factory, stdout)
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "HTML", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "name") {
t.Fatalf("expected name required error, got %v", err)
}
@@ -160,31 +159,20 @@ func TestAppsCreate_RequiresAppType(t *testing.T) {
}
}
// TestAppsCreate_RejectsInvalidAppType pins that --app-type is a strict
// lowercase enum (html / full_stack). Unknown values and legacy uppercase are
// both rejected by the flag's Enum — the CLI does not normalize case; legacy
// uppercase compatibility is a server-side concern, not surfaced by the client.
func TestAppsCreate_RejectsInvalidAppType(t *testing.T) {
for _, appType := range []string{"spa", "HTML", "Full_Stack"} {
t.Run(appType, func(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", appType, "--as", "user"},
factory, stdout)
if err == nil || !strings.Contains(err.Error(), "invalid value") {
t.Fatalf("expected invalid-enum error for %q, got %v", appType, err)
}
if !strings.Contains(err.Error(), "full_stack") {
t.Fatalf("expected enum error to list allowed values, got %v", err)
}
})
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "spa", "--as", "user"},
factory, stdout)
if err == nil || !strings.Contains(err.Error(), "not supported") {
t.Fatalf("expected unsupported app-type error, got %v", err)
}
}
func TestAppsCreate_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--dry-run", "--as", "user"},
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
@@ -195,55 +183,7 @@ func TestAppsCreate_DryRun(t *testing.T) {
if !strings.Contains(got, `"name": "Demo"`) {
t.Fatalf("dry-run missing body: %s", got)
}
if !strings.Contains(got, `"app_type": "html"`) {
if !strings.Contains(got, `"app_type": "HTML"`) {
t.Fatalf("dry-run missing app_type: %s", got)
}
}
func TestAppsCreate_FullstackSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{"app_id": "app_fs", "name": "Demo"},
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "full_stack", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["app_type"] != "full_stack" {
t.Fatalf("body.app_type = %v (want full_stack)", sent["app_type"])
}
if _, present := sent["message"]; present {
t.Fatalf("message should never be sent: %v", sent)
}
}
func TestAppsCreate_FullstackDryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "full_stack", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"app_type": "full_stack"`) {
t.Fatalf("dry-run missing app_type full_stack: %s", got)
}
if strings.Contains(got, `"message"`) {
t.Fatalf("dry-run should not contain message: %s", got)
}
}

View File

@@ -1,300 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsDBAuditList 列出数据表的行级审计事件INSERT/UPDATE/DELETE 的变更追溯)。
//
// GET /apps/{app_id}/db/audit_listcursor 分页)。--table 可重复传多张表;--since/--until 多格式时间。
// operator 透传 {id,name}json 还原对象、pretty 取 namebefore/after 是条件出现的 JSON
// INSERT 无 before、DELETE 无 afterjson 还原成对象。
//
// 多表查询时CLI 先用 schema表是否存在+ status审计是否开启在本地过滤把不存在 /
// 未开启审计的表剔除后再查 audit_list被剔除的表及原因放进 skipped服务端不再返该字段
var AppsDBAuditList = common.Shortcut{
Service: appsService,
Command: "+db-audit-list",
Description: "List row-change audit events for one or more tables (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-audit-list --app-id <app_id> --table orders",
"Multiple tables: repeat --table; filter time with --since 7d / --until 2026-04-15.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
{Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if len(auditListTables(rctx)) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required (at least one table)").WithParam("--table")
}
return normalizeTimeFlags(rctx, "since", "until")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appAuditListPath(appID)).
Desc("List Miaoda app table audit events").
Params(buildAuditListParams(rctx, auditListTables(rctx)))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
requested := auditListTables(rctx)
env := rctx.Str("env")
// 多表查询CLI 侧先用 schema表是否存在+ status审计是否开启过滤
// 不存在 / 未开启审计的表不进 audit_list 查询,单独在 skipped 里给出原因。
// 单表查询直接打 audit_list由后端就 table-not-found / audit-not-enabled 报错。
queryTables := requested
var skipped []auditSkippedEntry
if len(requested) > 1 {
queryTables, skipped, err = filterAuditTables(rctx, appID, env, requested)
if err != nil {
return withAppsHint(err, dbChangelogHint)
}
// 所有请求表都被过滤掉 → 无可查询表,直接返回空 + skipped 提示,不调 audit_list。
if len(queryTables) == 0 {
out := map[string]interface{}{"items": []auditLogItem{}, "has_more": false, "skipped": skipped}
rctx.OutFormat(out, nil, func(w io.Writer) {
io.WriteString(w, "No audit events found.\n")
writeAuditSkipped(w, skipped, len(requested))
})
return nil
}
}
data, err := rctx.CallAPITyped("GET", appAuditListPath(appID), buildAuditListParams(rctx, queryTables), nil)
if err != nil {
return withAppsHint(err, dbChangelogHint)
}
items := projectAuditLogItems(data["items"])
data["items"] = items
// 服务端不再返 skipped改由 CLI 算出的 skipped 写回输出。
if len(skipped) > 0 {
data["skipped"] = skipped
} else {
delete(data, "skipped")
}
multi := len(requested) > 1
rctx.OutFormat(data, nil, func(w io.Writer) {
renderAuditListPretty(w, items, skipped, len(requested), multi)
})
return nil
},
}
// auditSkippedEntry 是被 CLI 预过滤掉的表及原因(替代已删除的服务端 skipped 字段)。
type auditSkippedEntry struct {
Table string `json:"table"`
Reason string `json:"reason"`
}
// filterAuditTables 用 schema存在性+ status审计开关把请求表分成「可查询」与「跳过」两组。
func filterAuditTables(rctx *common.RuntimeContext, appID, env string, requested []string) ([]string, []auditSkippedEntry, error) {
existing, err := fetchExistingTables(rctx, appID, env)
if err != nil {
return nil, nil, err
}
enabled, err := fetchAuditEnabledTables(rctx, appID, env)
if err != nil {
return nil, nil, err
}
valid := make([]string, 0, len(requested))
var skipped []auditSkippedEntry
for _, t := range requested {
switch {
case !existing[t]:
skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "table not found"})
case !enabled[t]:
skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "audit not enabled"})
default:
valid = append(valid, t)
}
}
return valid, skipped, nil
}
// fetchExistingTables 翻页拉全量表清单返回存在表名集合schema 命令同源接口)。
func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
existing := map[string]bool{}
token := ""
for {
params := map[string]interface{}{"env": env, "page_size": 100}
if token != "" {
params["page_token"] = token
}
data, err := rctx.CallAPITyped("GET", appTablesPath(appID), params, nil)
if err != nil {
return nil, err
}
for _, it := range asMapSlice(data["items"]) {
if name := common.GetString(it, "name"); name != "" {
existing[name] = true
}
}
token = common.GetString(data, "page_token")
if data["has_more"] != true || token == "" {
break
}
}
return existing, nil
}
// fetchAuditEnabledTables 拉审计状态返回当前已开启审计的表名集合status 命令同源接口)。
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
if err != nil {
return nil, err
}
enabled := map[string]bool{}
for _, it := range asMapSlice(data["items"]) {
if it["enabled"] == true {
if name := common.GetString(it, "table"); name != "" {
enabled[name] = true
}
}
}
return enabled, nil
}
// asMapSlice 把 interface{}[]interface{})里的每个 map 元素取出,非 map 丢弃。
func asMapSlice(raw interface{}) []map[string]interface{} {
arr, _ := raw.([]interface{})
out := make([]map[string]interface{}, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}
// auditListTables 取 --table 切片trim 去空。
func auditListTables(rctx *common.RuntimeContext) []string {
out := make([]string, 0)
for _, t := range rctx.StrSlice("table") {
if v := strings.TrimSpace(t); v != "" {
out = append(out, v)
}
}
return out
}
// buildAuditListParams 组装 audit_list 查询参数env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
params := map[string]interface{}{
"env": rctx.Str("env"),
"tables": strings.Join(tables, ","),
"page_size": rctx.Int("page-size"),
}
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v
}
}
addStr("since", "since")
addStr("until", "until")
addStr("page-token", "page_token")
return params
}
type auditLogItem struct {
EventID string `json:"event_id"`
EventTime string `json:"event_time"`
TargetTable string `json:"target_table"`
Type string `json:"type"`
Operator *operatorRef `json:"operator,omitempty"`
Summary string `json:"summary"`
Before interface{} `json:"before,omitempty"`
After interface{} `json:"after,omitempty"`
}
// projectAuditLogItems 把服务端原始审计事件投影为白名单 auditLogItemoperator 解析、before/after 还原成对象)。
func projectAuditLogItems(raw interface{}) []auditLogItem {
arr, _ := raw.([]interface{})
out := make([]auditLogItem, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
row := auditLogItem{
EventID: common.GetString(m, "event_id"),
EventTime: common.GetString(m, "event_time"),
TargetTable: common.GetString(m, "target_table"),
Type: common.GetString(m, "type"),
Operator: parseOperator(common.GetString(m, "operator")),
Summary: common.GetString(m, "summary"),
}
// before/after 条件出现INSERT 无 before、DELETE 无 after。JSON 字符串 → 还原对象。
if b := common.GetString(m, "before"); b != "" {
row.Before = safeParseJSON(b)
}
if a := common.GetString(m, "after"); a != "" {
row.After = safeParseJSON(a)
}
out = append(out, row)
}
return out
}
// renderAuditListPretty 单表 5 列 / 多表 6 列(首列 target_table末尾列出 skipped 表。
func renderAuditListPretty(w io.Writer, items []auditLogItem, skipped []auditSkippedEntry, totalRequested int, multi bool) {
if len(items) == 0 {
io.WriteString(w, "No audit events found.\n")
writeAuditSkipped(w, skipped, totalRequested)
return
}
var headers []string
if multi {
headers = []string{"target_table", "event_time", "type", "event_id", "operator", "summary"}
} else {
headers = []string{"event_time", "type", "event_id", "operator", "summary"}
}
rows := make([][]string, 0, len(items))
for _, it := range items {
cells := []string{dashIfEmpty(it.EventTime), it.Type, it.EventID, operatorName(it.Operator), dashIfEmpty(it.Summary)}
if multi {
cells = append([]string{dashIfEmpty(it.TargetTable)}, cells...)
}
rows = append(rows, cells)
}
renderAlignedTable(w, headers, rows)
writeAuditSkipped(w, skipped, totalRequested)
}
// writeAuditSkipped 打 "— Skipped N of M tables: orders (audit not enabled), foo (table not found)"。
func writeAuditSkipped(w io.Writer, skipped []auditSkippedEntry, totalRequested int) {
if len(skipped) == 0 {
return
}
parts := make([]string, 0, len(skipped))
for _, s := range skipped {
parts = append(parts, fmt.Sprintf("%s (%s)", s.Table, s.Reason))
}
fmt.Fprintf(w, "— Skipped %d of %d tables: %s\n", len(skipped), totalRequested, strings.Join(parts, ", "))
}

View File

@@ -1,142 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// 审计保留期合法取值。
var auditRetentions = []string{"7d", "30d", "180d", "360d", "forever"}
const dbAuditSetHint = "verify --app-id and --table; check current config with `lark-cli apps +db-audit-status --app-id <app_id>`"
// AppsDBAuditEnable 为某张表开启行级审计(变更追溯)。
//
// POST /apps/{app_id}/db/audit_setbody {table, enabled:true, retention}。--retention 默认 7d。
var AppsDBAuditEnable = common.Shortcut{
Service: appsService,
Command: "+db-audit-enable",
Description: "Enable row-change audit logging for a table",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +db-audit-enable --app-id <app_id> --table orders --retention 30d",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to enable audit for", Required: true},
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Enable table audit").
Params(map[string]interface{}{"env": rctx.Str("env")}).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
table := strings.TrimSpace(rctx.Str("table"))
retention := rctx.Str("retention")
stop := rctx.StartSpinner("Enabling audit logging for " + table)
defer stop()
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
map[string]interface{}{"env": rctx.Str("env")},
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
stop()
if err != nil {
return withAppsHint(err, dbAuditSetHint)
}
st := auditSetStatus(data, table)
ret := common.GetString(st, "retention")
if ret == "" {
ret = retention
}
out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": true, "retention": ret}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Audit enabled for table '%s' (retention: %s)\n", common.GetString(out, "table"), ret)
})
return nil
},
}
// AppsDBAuditDisable 关闭某张表的行级审计。
//
// POST /apps/{app_id}/db/audit_setbody {table, enabled:false}。
var AppsDBAuditDisable = common.Shortcut{
Service: appsService,
Command: "+db-audit-disable",
Description: "Disable row-change audit logging for a table",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +db-audit-disable --app-id <app_id> --table orders",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to disable audit for", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Disable table audit").
Params(map[string]interface{}{"env": rctx.Str("env")}).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
table := strings.TrimSpace(rctx.Str("table"))
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
map[string]interface{}{"env": rctx.Str("env")},
map[string]interface{}{"table": table, "enabled": false})
if err != nil {
return withAppsHint(err, dbAuditSetHint)
}
st := auditSetStatus(data, table)
out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": false}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Audit disabled for table '%s'\n", common.GetString(out, "table"))
})
return nil
},
}
// auditSetStatus 取响应里的 status 对象(缺失时用入参 table 兜底)。
func auditSetStatus(data map[string]interface{}, table string) map[string]interface{} {
if st, ok := data["status"].(map[string]interface{}); ok {
if common.GetString(st, "table") == "" {
st["table"] = table
}
return st
}
return map[string]interface{}{"table": table}
}

View File

@@ -1,139 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsDBAuditStatus 查看数据表的审计开关状态(哪些表开了行级审计、保留期)。
//
// GET /apps/{app_id}/db/audit_status。--table 指定单表(无记录时占位 enabled=false
// 不指定返回所有已配置表。json 单表返对象、多表返数组pretty 单表 key/value、多表表格。
var AppsDBAuditStatus = common.Shortcut{
Service: appsService,
Command: "+db-audit-status",
Description: "Show table audit (row-change tracking) status",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-audit-status --app-id <app_id>",
"Check one table: --table orders",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appAuditStatusPath(appID)).
Desc("Get table audit status").
Params(buildAuditStatusParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), buildAuditStatusParams(rctx), nil)
if err != nil {
return withAppsHint(err, dbChangelogHint)
}
table := strings.TrimSpace(rctx.Str("table"))
items := projectAuditStatusItems(data["items"])
// 单表查询但后端无记录 → 占位 enabled=false与 miaoda 一致)。
if table != "" && len(items) == 0 {
items = []map[string]interface{}{{"table": table, "enabled": false}}
}
// json单表返对象、多表返数组。
var out interface{}
if table != "" && len(items) == 1 {
out = items[0]
} else {
out = map[string]interface{}{"items": items}
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderAuditStatusPretty(w, items, table)
})
return nil
},
}
// buildAuditStatusParams 组装 audit_status 查询参数env 及可选 table单表查询
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{"env": rctx.Str("env")}
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
params["table"] = t
}
return params
}
// projectAuditStatusItems 透出 {table, enabled, enabled_at?, retention?}。
func projectAuditStatusItems(raw interface{}) []map[string]interface{} {
arr, _ := raw.([]interface{})
out := make([]map[string]interface{}, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
row := map[string]interface{}{
"table": common.GetString(m, "table"),
"enabled": m["enabled"] == true,
}
if v := common.GetString(m, "enabled_at"); v != "" {
row["enabled_at"] = v
}
if v := common.GetString(m, "retention"); v != "" {
row["retention"] = v
}
out = append(out, row)
}
return out
}
// renderAuditStatusPretty 单表渲染 key/value、多表渲染对齐表格table/enabled/enabled_at/retention
func renderAuditStatusPretty(w io.Writer, items []map[string]interface{}, table string) {
if len(items) == 0 {
io.WriteString(w, "No audit configuration found.\n")
return
}
yesNo := func(m map[string]interface{}) string {
if m["enabled"] == true {
return "yes"
}
return "no"
}
get := func(m map[string]interface{}, k string) string { return dashIfEmpty(common.GetString(m, k)) }
// 单表 → key/value
if table != "" && len(items) == 1 {
it := items[0]
renderKeyValuePairs(w, [][2]string{
{"table", common.GetString(it, "table")},
{"enabled", yesNo(it)},
{"enabled_at", get(it, "enabled_at")},
{"retention", get(it, "retention")},
})
return
}
// 多表 → 表格
headers := []string{"table", "enabled", "enabled_at", "retention"}
rows := make([][]string, 0, len(items))
for _, it := range items {
rows = append(rows, []string{common.GetString(it, "table"), yesNo(it), get(it, "enabled_at"), get(it, "retention")})
}
renderAlignedTable(w, headers, rows)
}

View File

@@ -1,316 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const (
dbAuditStatusURL = "/open-apis/spark/v1/apps/app_x/db/audit_status"
dbAuditSetURL = "/open-apis/spark/v1/apps/app_x/db/audit_set"
dbAuditListURL = "/open-apis/spark/v1/apps/app_x/db/audit_list"
dbTablesListURL = "/open-apis/spark/v1/apps/app_x/tables"
)
// ── audit-status ──
// TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder 验证单表查询无记录时返回 enabled:false 的占位对象(非数组)。
func TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditStatusURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
})
if err := runAppsShortcut(t, AppsDBAuditStatus,
[]string{"+db-audit-status", "--app-id", "app_x", "--table", "orders", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
// 单表无记录 → 占位对象 enabled:false不是数组
var env struct {
Data struct {
Table string `json:"table"`
Enabled bool `json:"enabled"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
if env.Data.Table != "orders" || env.Data.Enabled {
t.Fatalf("expected placeholder {orders,false}, got %+v", env.Data)
}
}
// TestAppsDBAuditStatus_MultiTablePrettyTable 验证多表 pretty 输出含 enabled/yes/no 列与 retention 值。
func TestAppsDBAuditStatus_MultiTablePrettyTable(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditStatusURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
map[string]interface{}{"table": "orders", "enabled": true, "enabled_at": "2026-04-15T10:30:00Z", "retention": "30d"},
map[string]interface{}{"table": "users", "enabled": false},
}}},
})
if err := runAppsShortcut(t, AppsDBAuditStatus,
[]string{"+db-audit-status", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "enabled") || !strings.Contains(got, "yes") || !strings.Contains(got, "no") || !strings.Contains(got, "30d") {
t.Fatalf("pretty table malformed:\n%s", got)
}
}
// ── audit-enable / disable ──
// TestAppsDBAuditEnable_RequiresTableAndValidRetention 验证缺 --table 报必填错、非法 --retention 报 ValidationError。
func TestAppsDBAuditEnable_RequiresTableAndValidRetention(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
// 缺 --table → cobra required, exit 1
if err := runAppsShortcut(t, AppsDBAuditEnable,
[]string{"+db-audit-enable", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected required --table error")
}
// 非法 retention → enum 校验 (validation)
factory2, stdout2, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBAuditEnable,
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "99d", "--as", "user"}, factory2, stdout2)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--retention" {
t.Fatalf("Param = %q, want --retention", ve.Param)
}
}
// TestAppsDBAuditEnable_DryRunAndSuccess 验证 dry-run 发出 enabled:true+retention 的 POST成功时打印 pretty 确认行。
func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) {
// dry-run body {table, enabled:true, retention}
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBAuditEnable,
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" {
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
}
// success
factory2, stdout2, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbAuditSetURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": true, "retention": "30d"}}},
})
if err := runAppsShortcut(t, AppsDBAuditEnable,
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout2.String(), "✓ Audit enabled for table 'orders' (retention: 30d)") {
t.Fatalf("pretty: %s", stdout2.String())
}
}
// TestAppsDBAuditDisable_DryRunAndSuccess 验证 dry-run 发出 enabled:false 的 POST成功时打印 pretty 确认行。
func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBAuditDisable,
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" {
t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body)
}
factory2, stdout2, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbAuditSetURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": false}}},
})
if err := runAppsShortcut(t, AppsDBAuditDisable,
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout2.String(), "✓ Audit disabled for table 'orders'") {
t.Fatalf("pretty: %s", stdout2.String())
}
}
// ── audit-list ──
// TestAppsDBAuditList_RequiresTable 验证缺 --table 时报必填错误。
func TestAppsDBAuditList_RequiresTable(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBAuditList,
[]string{"+db-audit-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected required --table error")
}
}
// TestAppsDBAuditList_DryRunJoinsTables 验证 dry-run 将多个 --table 合并为 tables=orders,users 且归一化 since。
func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBAuditList,
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--since", "7d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" {
t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"])
}
if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") {
t.Fatalf("since not normalized: %v", a.Params["since"])
}
}
// 单表查询:不预过滤、直接打 audit_list后端就 not-found/not-enabled 报错),无 skipped。
// TestAppsDBAuditList_SingleTableNoPreflight 验证单表查询不预过滤、operator/before/after 还原为对象、无 skipped。
func TestAppsDBAuditList_SingleTableNoPreflight(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditListURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"has_more": false, "page_token": "",
"items": []interface{}{map[string]interface{}{
"event_id": "01525", "event_time": "2026-04-16T10:30:00Z", "target_table": "users",
"type": "UPDATE", "operator": `{"id":"7311","name":"alice"}`, "summary": "UPDATE 1 field",
"before": `{"amount":100}`, "after": `{"amount":999}`,
}},
}},
})
if err := runAppsShortcut(t, AppsDBAuditList,
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "users", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// operator → 对象before/after → 还原成对象(非字符串)。
for _, want := range []string{`"name": "alice"`, `"before"`, `"amount": 100`, `"after"`, `"amount": 999`} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `"skipped"`) {
t.Errorf("single-table query must not emit skipped:\n%s", got)
}
if strings.Contains(got, `"before": "{`) {
t.Errorf("before should be an object, not a JSON string:\n%s", got)
}
}
// TestAppsDBAuditList_SingleTableEmptyPretty 验证单表无事件时不报错、pretty 打印 "No audit events found." 且无 Skipped。
func TestAppsDBAuditList_SingleTableEmptyPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditListURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
})
if err := runAppsShortcut(t, AppsDBAuditList,
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("empty audit list should NOT error (ok read), got %v", err)
}
got := stdout.String()
if !strings.Contains(got, "No audit events found.") || strings.Contains(got, "Skipped") {
t.Fatalf("expected empty, no skipped for single table:\n%s", got)
}
}
// 多表查询CLI 用 schema存在性+ status审计开关预过滤只把有效表传给 audit_list
// 不存在 / 未开启审计的表进 skipped。
// TestAppsDBAuditList_MultiTablePreflightFilters 验证多表查询用 schema+status 预过滤,仅传有效表,不存在/未开审计的表进 skipped。
func TestAppsDBAuditList_MultiTablePreflightFilters(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
// schemaorders/users/carts 存在ghost 不存在。
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbTablesListURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
map[string]interface{}{"name": "orders"}, map[string]interface{}{"name": "users"}, map[string]interface{}{"name": "carts"},
}}},
})
// statusorders/users 开启审计carts 未开启。
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditStatusURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
map[string]interface{}{"table": "orders", "enabled": true}, map[string]interface{}{"table": "users", "enabled": true},
map[string]interface{}{"table": "carts", "enabled": false},
}}},
})
// audit_list 只应被传入有效表 orders,users。
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditListURL,
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("tables"); got != "orders,users" {
t.Errorf("audit_list tables = %q, want orders,users (filtered)", got)
}
},
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
map[string]interface{}{"event_id": "e1", "event_time": "2026-04-16T10:30:00Z", "target_table": "orders", "type": "INSERT", "summary": "INSERT"},
}}},
})
if err := runAppsShortcut(t, AppsDBAuditList,
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--table", "carts", "--table", "ghost", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// skippedcarts(audit not enabled) + ghost(table not found),结构化 {table,reason}。
for _, want := range []string{`"skipped"`, `"table": "carts"`, `"reason": "audit not enabled"`, `"table": "ghost"`, `"reason": "table not found"`} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
}
// 多表查询且全部被过滤掉 → 不调 audit_list直接空 + skipped 提示。
// TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery 验证多表全部被过滤时跳过 audit_list 调用,直接输出空结果加 Skipped 提示。
func TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbTablesListURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
map[string]interface{}{"name": "orders"},
}}},
})
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditStatusURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
})
// 不注册 audit_list若被调用会命中未注册请求而报错。
if err := runAppsShortcut(t, AppsDBAuditList,
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "ghost1", "--table", "ghost2", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("all-filtered should still succeed (empty), got %v", err)
}
got := stdout.String()
if !strings.Contains(got, "No audit events found.") || !strings.Contains(got, "Skipped 2 of 2 tables") {
t.Fatalf("expected empty + 'Skipped 2 of 2 tables':\n%s", got)
}
}

View File

@@ -1,150 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const dbChangelogHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
// AppsDBChangelogList 列出应用数据库的 DDL 变更记录(建表/改表/索引等结构变更追溯)。
//
// GET /apps/{app_id}/db/changelog_listcursor 分页)。过滤:--table、--since/--until多格式时间
// --change-id 精确查单条命中返单条、否则空。operator 后端以 JSON 字符串透传 {id,name}
// json 还原成对象、pretty 只展示 name。
var AppsDBChangelogList = common.Shortcut{
Service: appsService,
Command: "+db-changelog-list",
Description: "List a Miaoda app database's DDL change history (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-changelog-list --app-id <app_id>",
"Pin a single change with --change-id; filter time with --since 7d / --until 2026-04-15.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
{Name: "table", Desc: "filter by target table"},
{Name: "change-id", Desc: "look up a single change by id (returns that one record only)"},
{Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return normalizeTimeFlags(rctx, "since", "until")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appChangelogListPath(appID)).
Desc("List Miaoda app DDL changelog").
Params(buildChangelogParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appChangelogListPath(appID), buildChangelogParams(rctx), nil)
if err != nil {
return withAppsHint(err, dbChangelogHint)
}
items := projectChangelogItems(data["items"])
data["items"] = items
changeID := strings.TrimSpace(rctx.Str("change-id"))
rctx.OutFormat(data, nil, func(w io.Writer) {
renderChangelogPretty(w, items, changeID)
})
return nil
},
}
// buildChangelogParams 组装 changelog_list 查询参数env / page_size 及可选 table/change_id/since/until/page_token。
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"env": rctx.Str("env"),
"page_size": rctx.Int("page-size"),
}
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v
}
}
addStr("table", "table")
addStr("change-id", "change_id")
addStr("since", "since")
addStr("until", "until")
addStr("page-token", "page_token")
return params
}
type changelogItem struct {
ChangeID string `json:"change_id"`
ChangedAt string `json:"changed_at"`
Operator *operatorRef `json:"operator,omitempty"`
TargetTable string `json:"target_table"`
ChangeType string `json:"change_type"`
Summary string `json:"summary"`
Statement string `json:"statement,omitempty"`
}
// projectChangelogItems 把服务端原始 DDL 变更记录投影为白名单 changelogItemoperator 解析成对象)。
func projectChangelogItems(raw interface{}) []changelogItem {
arr, _ := raw.([]interface{})
out := make([]changelogItem, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
out = append(out, changelogItem{
ChangeID: common.GetString(m, "change_id"),
ChangedAt: common.GetString(m, "changed_at"),
Operator: parseOperator(common.GetString(m, "operator")),
TargetTable: common.GetString(m, "target_table"),
ChangeType: common.GetString(m, "change_type"),
Summary: common.GetString(m, "summary"),
Statement: common.GetString(m, "statement"),
})
}
return out
}
// renderChangelogPretty 6 列change_id / changed_at / operator(name) / target_table / change_type / summary。
func renderChangelogPretty(w io.Writer, items []changelogItem, changeID string) {
if len(items) == 0 {
if changeID != "" {
fmt.Fprintf(w, "No DDL change with id=%s found.\n", changeID)
} else {
io.WriteString(w, "No DDL changes found.\n")
}
return
}
headers := []string{"change_id", "changed_at", "operator", "target_table", "change_type", "summary"}
rows := make([][]string, 0, len(items))
for _, it := range items {
rows = append(rows, []string{
it.ChangeID,
dashIfEmpty(it.ChangedAt),
operatorName(it.Operator),
dashIfEmpty(it.TargetTable),
it.ChangeType,
dashIfEmpty(it.Summary),
})
}
renderAlignedTable(w, headers, rows)
}

View File

@@ -1,143 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const dbChangelogURL = "/open-apis/spark/v1/apps/app_x/db/changelog_list"
// TestAppsDBChangelogList_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。
func TestAppsDBChangelogList_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBChangelogList,
[]string{"+db-changelog-list", "--app-id", " ", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--app-id" {
t.Fatalf("Param = %q, want --app-id", ve.Param)
}
}
// TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize 验证 dry-run 透传 env/table/change_id 过滤参数并将 since 归一化为 RFC3339 UTC。
func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBChangelogList,
[]string{"+db-changelog-list", "--app-id", "app_x", "--env", "dev", "--table", "orders",
"--change-id", "01J", "--since", "2026-01-01", "--page-size", "5", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbChangelogURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Params["env"] != "dev" || a.Params["table"] != "orders" || a.Params["change_id"] != "01J" {
t.Fatalf("params = %v", a.Params)
}
if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") {
t.Fatalf("since not normalized to RFC3339 UTC: %v", a.Params["since"])
}
}
// TestAppsDBChangelogList_RejectsBadSince 验证不可解析的 --since 报 --since 的 ValidationError。
func TestAppsDBChangelogList_RejectsBadSince(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBChangelogList,
[]string{"+db-changelog-list", "--app-id", "app_x", "--since", "notatime", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--since" {
t.Fatalf("Param = %q, want --since", ve.Param)
}
}
// TestAppsDBChangelogList_SuccessParsesOperator 验证成功响应中 operator JSON 串被解析为对象并输出变更字段。
func TestAppsDBChangelogList_SuccessParsesOperator(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbChangelogURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"has_more": false, "page_token": "",
"items": []interface{}{map[string]interface{}{
"change_id": "01J", "changed_at": "2026-04-15T10:30:00Z",
"operator": `{"id":"7311","name":"alice"}`, "target_table": "orders",
"change_type": "ALTER_TABLE", "summary": "add column", "statement": "ALTER TABLE orders ...",
}},
}},
})
if err := runAppsShortcut(t, AppsDBChangelogList,
[]string{"+db-changelog-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{`"operator"`, `"name": "alice"`, `"id": "7311"`, `"change_type": "ALTER_TABLE"`, `"statement"`} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
}
// TestAppsDBChangelogList_ChangeIDNotFoundPretty 验证按 --change-id 查询无结果时 pretty 打印 not-found 提示。
func TestAppsDBChangelogList_ChangeIDNotFoundPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbChangelogURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
})
if err := runAppsShortcut(t, AppsDBChangelogList,
[]string{"+db-changelog-list", "--app-id", "app_x", "--change-id", "nope", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "No DDL change with id=nope found.") {
t.Fatalf("expected not-found message, got: %s", stdout.String())
}
}
// TestParseOperator_Cases 验证 parseOperator 处理合法 JSON、空 name 回退 id、非 JSON 原样、空串返回 nil以及 operatorName(nil) 为占位符。
func TestParseOperator_Cases(t *testing.T) {
if op := parseOperator(`{"id":"1","name":"a"}`); op == nil || op.ID != "1" || op.Name != "a" {
t.Fatalf("valid: %#v", op)
}
if op := parseOperator(`{"id":"1","name":""}`); op == nil || op.Name != "1" {
t.Fatalf("name fallback to id: %#v", op)
}
if op := parseOperator("plain-user"); op == nil || op.ID != "plain-user" || op.Name != "plain-user" {
t.Fatalf("non-json raw: %#v", op)
}
if op := parseOperator(""); op != nil {
t.Fatalf("empty → nil, got %#v", op)
}
if operatorName(nil) != "—" {
t.Fatalf("nil operatorName should be —")
}
}
// TestSafeParseJSON_Cases 验证 safeParseJSON 合法 JSON 解析为对象、非法 JSON 原样返回字符串。
func TestSafeParseJSON_Cases(t *testing.T) {
if v := safeParseJSON(`{"a":1}`); v == nil {
t.Fatalf("valid json → object")
}
if v, ok := safeParseJSON("not json").(string); !ok || v != "not json" {
t.Fatalf("invalid json → raw string, got %v", v)
}
}

View File

@@ -1,189 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
)
const dbDataExportMaxRows = 5000
const dbDataExportMaxBytes = 1 * 1024 * 1024 // 1 MB
const dbDataExportHint = "verify --app-id and --table; if too large, filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets"
// AppsDBDataExport 把应用数据表导出到本地文件csv/json/sql
//
// GET /apps/{app_id}/db/data_export返回原始字节非 JSON 信封)。
// 行数不随导出文件返回CLI 原子编排——先查 GetAppTableRecordList 的 total再导出文件。
// 数据格式由 --output 扩展名推断(默认 csv缺省输出 <table>.csv上限 5000 行 / 1 MB。
var AppsDBDataExport = common.Shortcut{
Service: appsService,
Command: "+db-data-export",
Description: "Export rows from a Miaoda app table to a local file (csv/json/sql)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-data-export --app-id <app_id> --table orders --output ./orders.csv",
"Format follows the --output extension: .csv / .json / .sql (default csv).",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "source table", Required: true},
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "source db environment"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("table")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required").WithParam("--table")
}
if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit")
}
if _, _, err := exportFormatAndOutput(rctx); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
format, _, _ := exportFormatAndOutput(rctx)
return common.NewDryRunAPI().
GET(appDataExportPath(appID)).
Desc("Export Miaoda app table data (raw bytes)").
Params(map[string]interface{}{
"env": rctx.Str("env"), "table": strings.TrimSpace(rctx.Str("table")),
"format": format, "limit": rctx.Int("limit"),
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
table := strings.TrimSpace(rctx.Str("table"))
format, out, err := exportFormatAndOutput(rctx)
if err != nil {
return err
}
// 原子编排第 1 步先查总行数records 列表的 total再导出文件。
// total 查询失败不阻断导出——回退到按导出文件内容数行。
total, totalErr := queryExportTotal(rctx, appID, rctx.Str("env"), table)
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: larkcore.QueryParams{
"env": []string{rctx.Str("env")},
"table": []string{table},
"format": []string{format},
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
},
})
if err != nil {
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
}
// 成功是原始字节;业务错误网关以 JSON 信封 {code,msg} 返回(以 '{' 开头)。
if b := bytes.TrimSpace(resp.RawBody); len(b) > 0 && b[0] == '{' {
if _, cerr := rctx.ClassifyAPIResponse(resp); cerr != nil {
return withAppsHint(cerr, dbDataExportHint)
}
}
if resp.StatusCode >= 400 {
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkServer, "export failed: HTTP %d", resp.StatusCode).WithRetryable(), dbDataExportHint)
}
body := resp.RawBody
if len(body) > dbDataExportMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "export exceeds 1 MB limit (%d bytes); filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets", len(body))
}
saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: int64(len(body)),
}, bytes.NewReader(body))
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output")
}
// 行数取自预查的 total导出最多 limit 行,故取 mintotal 查询失败时按导出内容数行兜底。
rows := 0
if totalErr == nil {
rows = total
if lim := rctx.Int("limit"); rows > lim {
rows = lim
}
} else {
rows = countDataRows(body, format)
}
resolved, perr := rctx.FileIO().ResolvePath(out)
if perr != nil || resolved == "" {
resolved = out
}
result := map[string]interface{}{
"table": table, "output": resolved, "format": format,
"rows": rows, "size_bytes": saved.Size(),
}
rctx.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Exported %s → %s (%d rows)\n", table, resolved, rows)
})
return nil
},
}
// queryExportTotal 调 GetAppTableRecordListpage_size=1取 total符合条件的记录总数
// 该接口与 +db-data-export 同为 spark:app:read scope避免导出命令被迫升级到写权限。
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
map[string]interface{}{"env": env, "page_size": 1}, nil)
if err != nil {
return 0, err
}
return totalAsInt(raw["total"]), nil
}
// totalAsInt 把 total 解析成 int兼容 JSON number 与 i64-as-string 两种 wire 形态。
func totalAsInt(v interface{}) int {
if f, ok := numericAsFloat(v); ok {
return int(f)
}
if s, ok := v.(string); ok {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
return n
}
}
return 0
}
// exportFormatAndOutput 由 --output 推断数据格式与落盘路径:
// 给了 --output → 取其扩展名定 formatcsv/json/sql未给 → 默认 csv、输出 <table>.csv。
func exportFormatAndOutput(rctx *common.RuntimeContext) (format, outPath string, err error) {
table := strings.TrimSpace(rctx.Str("table"))
out := strings.TrimSpace(rctx.Str("output"))
if out == "" {
return "csv", table + ".csv", nil
}
f, ferr := resolveDataFormat(filepath.Ext(out), true)
if ferr != nil {
return "", "", ferr
}
return f, out, nil
}

View File

@@ -1,193 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const dbDataExportURL = "/open-apis/spark/v1/apps/app_x/db/data_export"
const dbOrdersRecordsURL = "/open-apis/spark/v1/apps/app_x/tables/orders/records"
// TestAppsDBDataExport_RequiresTable 验证缺 --table 时报必填错误。
func TestAppsDBDataExport_RequiresTable(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
// 缺 --table → cobra required-flag, exit 1
err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected required-flag error for missing --table")
}
}
// TestAppsDBDataExport_RejectsBadLimit 验证越界 --limit0/-1/5001均报 --limit 的 ValidationError。
func TestAppsDBDataExport_RejectsBadLimit(t *testing.T) {
for _, lim := range []string{"0", "-1", "5001"} {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--limit", lim, "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("limit=%s err = %T %v, want *errs.ValidationError", lim, err, err)
}
if ve.Param != "--limit" {
t.Fatalf("limit=%s Param = %q, want --limit", lim, ve.Param)
}
}
}
// TestAppsDBDataExport_RejectsBadOutputExtension 验证不支持的 --output 扩展名(.xml报校验错误。
func TestAppsDBDataExport_RejectsBadOutputExtension(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "dump.xml", "--as", "user"}, factory, stdout)
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected unsupported-format validation for .xml, got %v", err)
}
}
// dry-runformat 跟随 --output 扩展名;缺省 csv。
// TestAppsDBDataExport_DryRunFormatFromOutput 验证 dry-run 的 format 参数跟随 --output 扩展名、缺省为 csv并带 limit。
func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) {
cases := []struct{ output, wantFmt string }{
{"", "csv"}, {"orders.csv", "csv"}, {"orders.json", "json"}, {"dump.sql", "sql"},
}
for _, c := range cases {
factory, stdout, _ := newAppsExecuteFactory(t)
args := []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}
if c.output != "" {
args = append(args, "--output", c.output)
}
if err := runAppsShortcut(t, AppsDBDataExport, args, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbDataExportURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Params["format"] != c.wantFmt || a.Params["table"] != "orders" {
t.Errorf("output=%q params.format=%v want %q", c.output, a.Params["format"], c.wantFmt)
}
if _, ok := a.Params["limit"]; !ok {
t.Errorf("dry-run missing limit param")
}
}
}
// 成功:先查 records 列表 total 计行,再把原始字节落盘。
// TestAppsDBDataExport_SuccessWritesFile 验证成功路径先查 records total 计行、再将导出原始字节落盘并输出 rows/format/table。
func TestAppsDBDataExport_SuccessWritesFile(t *testing.T) {
dir := chdirTemp(t)
factory, stdout, reg := newAppsExecuteFactory(t)
// 第 1 步records 列表 total=2行数来源
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbOrdersRecordsURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 2, "has_more": false, "items": "[]"}},
})
// 第 2 步:导出原始字节。
reg.Register(&httpmock.Stub{
Method: "GET",
URL: dbDataExportURL,
RawBody: []byte("id,name\n1,a\n2,b\n"),
Headers: http.Header{"Content-Type": []string{"text/csv"}},
})
if err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
b, err := os.ReadFile(dir + "/orders.csv")
if err != nil || string(b) != "id,name\n1,a\n2,b\n" {
t.Fatalf("output file wrong: %q err=%v", string(b), err)
}
got := stdout.String()
if !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"format": "csv"`) || !strings.Contains(got, `"table": "orders"`) {
t.Fatalf("output json missing fields:\n%s", got)
}
}
// 行数取自 records total且按 --limit 截顶min(total, limit))。
// TestAppsDBDataExport_RowsFromTotalCappedByLimit 验证行数取 records total 并按 --limit 截顶total=10000、limit=100 → rows=100
func TestAppsDBDataExport_RowsFromTotalCappedByLimit(t *testing.T) {
chdirTemp(t)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbOrdersRecordsURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 10000, "has_more": true, "items": "[]"}},
})
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbDataExportURL,
RawBody: []byte("id\n1\n2\n3\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
})
if err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--limit", "100", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), `"rows": 100`) {
t.Fatalf("expected rows capped to limit 100 from total=10000:\n%s", stdout.String())
}
}
// total 查询失败records 列表报错)→ 回退按导出文件内容数行,不阻断导出。
// TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable 验证 records total 查询失败时回退按导出文件内容数行,不阻断落盘。
func TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable(t *testing.T) {
dir := chdirTemp(t)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbOrdersRecordsURL,
Body: map[string]interface{}{"code": 1254000, "msg": "records unavailable"},
})
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbDataExportURL,
RawBody: []byte("id,name\n1,a\n2,b\n3,c\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
})
if err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("export should still succeed via fallback, got %v", err)
}
b, _ := os.ReadFile(dir + "/orders.csv")
if string(b) != "id,name\n1,a\n2,b\n3,c\n" {
t.Fatalf("file not written on fallback path: %q", string(b))
}
if !strings.Contains(stdout.String(), `"rows": 3`) {
t.Fatalf("expected fallback file-count rows:3:\n%s", stdout.String())
}
}
// 业务错误:网关回 JSON 信封 {code,msg}(非原始字节)→ typed error不落盘。
// TestAppsDBDataExport_BusinessErrorEnvelope 验证响应为 JSON 错误信封(非原始字节)时返回 typed error 且不落盘。
func TestAppsDBDataExport_BusinessErrorEnvelope(t *testing.T) {
chdirTemp(t)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: dbDataExportURL,
RawBody: []byte(`{"code":1254043,"msg":"table not found"}`),
Headers: http.Header{"Content-Type": []string{"application/json"}},
})
err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--table", "nope", "--output", "nope.csv", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String())
}
if _, statErr := os.Stat("nope.csv"); statErr == nil {
t.Fatalf("error path must not write the output file")
}
}

View File

@@ -1,142 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
const dbDataImportMaxBytes = 1 * 1024 * 1024 // 1 MB
const dbDataImportHint = "verify --app-id and --table; data file must be .csv/.json and ≤1 MB — split larger files and import in batches"
// AppsDBDataImport 把本地 csv/json 文件直传到应用数据表high-risk-write
//
// POST /apps/{app_id}/db/data_importmultipart 表单file_name + 可选 table + 文件本体(与
// +file-upload / UploadFileForOpenAPI 一致)。文件的格式解析与转换在服务端 integration 层完成
// (按 file_name 扩展名推断 csv/jsonCLI 不再本地解析。表名缺省取文件名(去扩展名)。上限 1 MB。
var AppsDBDataImport = common.Shortcut{
Service: appsService,
Command: "+db-data-import",
Description: "Import rows from a local csv/json file into a Miaoda app table",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +db-data-import --app-id <app_id> --file ./orders.csv --yes",
"Table defaults to the file name; override with --table.",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
{Name: "table", Desc: "target table (default: file name without extension)"},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("file")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
// 文件名即可校验格式(服务端按扩展名推断)与推断表名,无需读取内容。
if _, err := resolveDataFormat(filepath.Ext(rctx.Str("file")), false); err != nil {
return err
}
// 体积守卫前移到 Validate用 Stat 先查大小不读内容dry-run 也能拦超大文件、且
// 在读整个文件进内存之前就失败(对齐 +file-upload。Stat 失败不在此报错,留给 Execute
// 的 ReadInputFile 产出更精确的「文件不存在/越界」错误。
if st, serr := rctx.FileIO().Stat(strings.TrimSpace(rctx.Str("file"))); serr == nil && st.Size() > dbDataImportMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", st.Size()).WithParam("--file")
}
if importTableName(rctx) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot infer target table from file name; specify --table").WithParam("--table")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
fileName := filepath.Base(strings.TrimSpace(rctx.Str("file")))
return common.NewDryRunAPI().
POST(appDataImportPath(appID)).
Desc("Import data file into Miaoda app table (multipart upload)").
Params(map[string]interface{}{"env": rctx.Str("env"), "table": importTableName(rctx)}).
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
file := strings.TrimSpace(rctx.Str("file"))
content, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file")
}
if len(content) > dbDataImportMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", len(content)).WithParam("--file")
}
fileName := filepath.Base(file)
table := importTableName(rctx)
// multipartfile_name 走表单字段、文件本体走 form-filesenv / table 走 query。
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddFile("file", bytes.NewReader(content))
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: appDataImportPath(appID),
QueryParams: larkcore.QueryParams{"env": []string{rctx.Str("env")}, "table": []string{table}},
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "import request failed").WithCause(err).WithRetryable(), dbDataImportHint)
}
data, err := rctx.ClassifyAPIResponse(resp)
if err != nil {
return withAppsHint(err, dbDataImportHint)
}
outTable := common.GetString(data, "table")
if outTable == "" {
outTable = table
}
rows := int64(0)
if f, ok := numericAsFloat(data["rows"]); ok {
rows = int64(f)
}
out := map[string]interface{}{"file": file, "table": outTable, "rows": rows}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Imported %s → table '%s' (%d rows)\n", file, outTable, rows)
})
return nil
},
}
// importTableName 取目标表名:--table 优先,否则文件名去扩展名。
func importTableName(rctx *common.RuntimeContext) string {
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
return t
}
f := strings.TrimSpace(rctx.Str("file"))
if f == "" {
return ""
}
base := filepath.Base(f)
return strings.TrimSuffix(base, filepath.Ext(base))
}

View File

@@ -1,161 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const dbDataImportURL = "/open-apis/spark/v1/apps/app_x/db/data_import"
// chdirTemp 切到临时工作目录(--file 走 cwd 内相对路径),返回该目录。
func chdirTemp(t *testing.T) string {
t.Helper()
dir := t.TempDir()
old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
return dir
}
// TestAppsDBDataImport_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。
func TestAppsDBDataImport_RequiresAppID(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", " ", "--file", "orders.csv", "--yes", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--app-id" {
t.Fatalf("Param = %q, want --app-id", ve.Param)
}
}
// TestAppsDBDataImport_RejectsUnsupportedFormat 验证非 csv/json 文件(.txt报不支持格式的校验错误。
func TestAppsDBDataImport_RejectsUnsupportedFormat(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("data.txt", []byte("x\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "data.txt", "--yes", "--as", "user"}, factory, stdout)
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected unsupported-format validation, got %v", err)
}
}
// TestAppsDBDataImport_RequiresConfirmation 验证缺 --yes 时报 requires confirmation 错误。
func TestAppsDBDataImport_RequiresConfirmation(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected confirmation_required, got %v", err)
}
}
// TestAppsDBDataImport_RejectsOversizeFile 验证超过 1MB 上限的文件报 --file 的 ValidationError。
func TestAppsDBDataImport_RejectsOversizeFile(t *testing.T) {
chdirTemp(t)
// >1MB → size 校验
big := append([]byte("id\n"), make([]byte, dbDataImportMaxBytes+1)...)
_ = os.WriteFile("big.csv", big, 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "big.csv", "--yes", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected 1MB limit error, got %T %v", err, err)
}
if ve.Param != "--file" {
t.Fatalf("Param = %q, want --file", ve.Param)
}
}
// dry-runmultipart 上传——file_name + file 走 bodyenv + table 走 querytable 缺省取文件名)。
// TestAppsDBDataImport_DryRunMultipartShape 验证 dry-run 的 multipart 形态file_name+file 走 body、env+table 走 query 且不再发 format。
func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--env", "dev", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != dbDataImportURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Body["file_name"] != "orders.csv" || a.Body["file"] == nil {
t.Fatalf("dry-run body should carry file_name + file: %v", a.Body)
}
if _, ok := a.Body["format"]; ok {
t.Fatalf("format must no longer be sent: %v", a.Body)
}
if a.Params["env"] != "dev" || a.Params["table"] != "orders" {
t.Fatalf("dry-run params (env+table) = %v", a.Params)
}
}
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
func TestAppsDBDataImport_Success(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id,name\n1,a\n2,b\n"), 0o600)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbDataImportURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"table": "orders", "rows": 2}},
})
if err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--table", "orders", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"table": "orders"`) || !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"file": "orders.csv"`) {
t.Fatalf("output missing fields:\n%s", got)
}
}
// TestAppsDBDataImport_TableDefaultsToFileBasename 验证未传 --table 时表名缺省取文件名去扩展名customers.json→customers
func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("customers.json", []byte(`[{"id":1}]`), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Params["table"] != "customers" {
t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params)
}
}

View File

@@ -1,98 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id <app_id> --env dev`"
// AppsDBEnvCreate creates a DB environment for a Miaoda app拆分单库为 dev/online 多环境)。
//
// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。
// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
var AppsDBEnvCreate = common.Shortcut{
Service: appsService,
Command: "+db-env-create",
Description: "Create a DB environment (split single-env DB into dev/online, irreversible)",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +db-env-create --env dev --sync-data --app-id <app_id> --yes",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"},
{Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appDbEnvCreatePath(appID)).
Desc("Create Miaoda app DB environment").
Body(buildDBEnvCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", appDbEnvCreatePath(appID), nil, buildDBEnvCreateBody(rctx))
if err != nil {
return withAppsHint(err, dbEnvCreateHint)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderEnvCreatePretty(w, data)
})
return nil
},
}
// buildDBEnvCreateBody 构造 db 环境创建 bodysync_databool
// --env 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。
func buildDBEnvCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"sync_data": rctx.Bool("sync-data"),
}
}
// renderEnvCreatePretty 输出 4 行pretty 模式):
//
// ✓ Multi-env initialized
// Environments: dev, online
// Data synced: yes
// Note: structure changes in dev now need to be released to online.
func renderEnvCreatePretty(w io.Writer, data map[string]interface{}) {
fmt.Fprintln(w, "✓ Multi-env initialized")
if envs, ok := data["environments"].([]interface{}); ok && len(envs) > 0 {
names := make([]string, 0, len(envs))
for _, e := range envs {
if s, ok := e.(string); ok {
names = append(names, s)
}
}
fmt.Fprintf(w, "Environments: %s\n", strings.Join(names, ", "))
}
synced := "no"
if ds, ok := data["data_synced"].(bool); ok && ds {
synced = "yes"
}
fmt.Fprintf(w, "Data synced: %s\n", synced)
fmt.Fprintln(w, "Note: structure changes in dev now need to be released to online.")
}

View File

@@ -1,124 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsDBEnvCreate_WithYesPostsSyncData(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init", // URL 仍走 db_dev_initCLI 命令名 +db-env-create
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"status": "initialized",
"environments": []interface{}{"dev", "online"},
"data_synced": true,
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["sync_data"] != true {
t.Fatalf("body.sync_data = %v (want true)", sent["sync_data"])
}
if !strings.Contains(stdout.String(), "initialized") {
t.Fatalf("stdout should include status, got %s", stdout.String())
}
}
// 不传 --sync-data默认→ body.sync_data=false
func TestAppsDBEnvCreate_SyncDataFalseByDefault(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "initialized"}},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["sync_data"] != false {
t.Fatalf("body.sync_data = %v (want false by default)", sent["sync_data"])
}
}
func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"status": "initialized",
"environments": []interface{}{"dev", "online"},
"data_synced": true,
},
},
})
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
wantLines := []string{
"✓ Multi-env initialized",
"Environments: dev, online",
"Data synced: yes",
"Note: structure changes in dev now need to be released to online.",
}
for _, line := range wantLines {
if !strings.Contains(got, line) {
t.Errorf("pretty output missing line %q\ngot:\n%s", line, got)
}
}
}
func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/db_dev_init") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
}
// --env 只接受 dev传 online 应被 enum 校验拒绝。
func TestAppsDBEnvCreate_RejectsNonDevEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "online", "--yes", "--as", "user"},
factory, stdout)
if err == nil || !strings.Contains(err.Error(), "env") {
t.Fatalf("expected env enum rejection, got %v", err)
}
}

View File

@@ -1,191 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const dbEnvMigrateHint = "ensure the app is multi-env (`+db-env-create`) and has pending dev changes; preview with `+db-env-diff`"
// AppsDBEnvDiff 预览 dev→online 待发布的结构变更(不落地)。
//
// POST /apps/{app_id}/db/env_migratebody {dry_run:true},同步返 {from,to,changes[]}。
// 与 +db-env-migrate 同端点、dry_run 区分;预览也需 spark:app:write scope。
var AppsDBEnvDiff = common.Shortcut{
Service: appsService,
Command: "+db-env-diff",
Description: "Preview pending dev→online schema changes (no apply)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-env-diff --app-id <app_id>",
"Apply the previewed changes with +db-env-migrate --yes.",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Preview dev→online migration").Body(map[string]interface{}{"dry_run": true})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
stop := rctx.StartSpinner("Previewing migration diff (dev → online)")
defer stop()
data, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true})
stop()
if err != nil {
return withAppsHint(err, dbEnvMigrateHint)
}
from, to := common.GetString(data, "from"), common.GetString(data, "to")
changes := projectMigrationChanges(data["changes"])
out := map[string]interface{}{"from": from, "to": to, "changes": changes}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderMigrationDiff(w, from, to, changes)
})
return nil
},
}
// AppsDBEnvMigrate 把 dev 的待发布结构变更发布到 online异步CLI 轮询至完成)。
//
// POST /apps/{app_id}/db/env_migratebody {dry_run:false} → task_id轮询 env_migrate_status
// 至 success后端 status:appliedCLI 对外统一呈现 migrated。high-risk-write。
var AppsDBEnvMigrate = common.Shortcut{
Service: appsService,
Command: "+db-env-migrate",
Description: "Publish pending dev→online schema changes (irreversible)",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +db-env-migrate --app-id <app_id> --yes",
"Preview first with +db-env-diff.",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Apply dev→online migration").Body(map[string]interface{}{"dry_run": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
stop := rctx.StartSpinner("Applying migration (dev → online)")
defer stop()
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
if err != nil {
return withAppsHint(err, dbEnvMigrateHint)
}
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
taskID := common.GetString(submit, "task_id")
applied := intFromAny(submit["changes_applied"])
if applied == 0 {
applied = len(projectMigrationChanges(submit["changes"]))
}
// 有 task_id → 异步,轮询至终态;无 task_id同步完成则直接用 submit 结果。
if taskID != "" {
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "status")) {
case "success", "applied", "migrated":
return true, nil
case "failed":
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", migrateFailMsg(d, taskID)), dbEnvMigrateHint)
}
return false, nil
})
if perr != nil {
return perr
}
if n := intFromAny(final["changes_applied"]); n > 0 {
applied = n
}
}
stop() // clear spinner before printing the result
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Migrated %s → %s (%d changes)\n", from, to, applied)
})
return nil
},
}
type migrationChange struct {
Type string `json:"type"`
Table string `json:"table"`
Statement string `json:"statement"`
}
// projectMigrationChanges 把服务端原始变更项投影为白名单 migrationChangetype/table/statement
func projectMigrationChanges(raw interface{}) []migrationChange {
arr, _ := raw.([]interface{})
out := make([]migrationChange, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]interface{}); ok {
out = append(out, migrationChange{
Type: common.GetString(m, "type"),
Table: common.GetString(m, "table"),
Statement: common.GetString(m, "statement"),
})
}
}
return out
}
// renderMigrationDiff 渲染 dev→online 待发布变更:无变更打提示,否则逐条打 statement。
func renderMigrationDiff(w io.Writer, from, to string, changes []migrationChange) {
if len(changes) == 0 {
fmt.Fprintf(w, "No pending changes from %s to %s.\n", from, to)
return
}
fmt.Fprintf(w, "%s → %s (%d changes):\n\n", from, to, len(changes))
for _, c := range changes {
fmt.Fprintf(w, " %s\n", c.Statement)
}
}
// migrateFailMsg 取发布失败信息:优先服务端 error_message缺失则用带 task_id 的兜底文案。
func migrateFailMsg(d map[string]interface{}, taskID string) string {
if m := common.GetString(d, "error_message"); m != "" {
return m
}
return fmt.Sprintf("migration apply failed (task_id=%s)", taskID)
}
// intFromAny 把 JSON number / json.Number 转 int计数用
func intFromAny(v interface{}) int {
if f, ok := numericAsFloat(v); ok {
return int(f)
}
return 0
}

View File

@@ -1,369 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const (
dbEnvMigrateURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate"
dbEnvMigrateStatusURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate_status"
dbRecoveryURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery"
dbRecoveryDiffURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_diff_status"
dbRecoveryApplyURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_apply_status"
dbQuotaURL = "/open-apis/spark/v1/apps/app_x/db/quota"
)
// ── env-diff ──
// TestAppsDBEnvDiff_DryRunBody 校验 dry-run 请求体POST env_migrate 且 dry_run=true。
func TestAppsDBEnvDiff_DryRunBody(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBEnvDiff,
[]string{"+db-env-diff", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true {
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
}
}
// TestAppsDBEnvDiff_SuccessRendersChanges 验证 pretty 输出渲染出 dev → online 变更摘要及 DDL 语句。
func TestAppsDBEnvDiff_SuccessRendersChanges(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"from": "dev", "to": "online",
"changes": []interface{}{
map[string]interface{}{"type": "ALTER_TABLE", "table": "orders", "statement": "ALTER TABLE orders ADD COLUMN note text"},
},
}},
})
if err := runAppsShortcut(t, AppsDBEnvDiff,
[]string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "dev → online (1 changes)") || !strings.Contains(got, "ALTER TABLE orders ADD COLUMN note text") {
t.Fatalf("pretty diff malformed:\n%s", got)
}
}
// TestAppsDBEnvDiff_EmptyChanges 验证无变更时 pretty 输出"无待发布变更"提示。
func TestAppsDBEnvDiff_EmptyChanges(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "changes": []interface{}{}}},
})
if err := runAppsShortcut(t, AppsDBEnvDiff,
[]string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "No pending changes from dev to online.") {
t.Fatalf("expected empty message, got: %s", stdout.String())
}
}
// ── env-migrate ──
// TestAppsDBEnvMigrate_DryRunBody 校验 migrate 的 dry-run 请求体里 dry_run=false真实迁移
func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBEnvMigrate,
[]string{"+db-env-migrate", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Body["dry_run"] != false {
t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body)
}
}
// 异步submit 返 task_idstatus 立刻 applied → CLI 对外统一 migrated。
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
})
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbEnvMigrateStatusURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "applied", "changes_applied": 3}},
})
if err := runAppsShortcut(t, AppsDBEnvMigrate,
[]string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "✓ Migrated dev → online (3 changes)") {
t.Fatalf("pretty: %s", got)
}
}
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
})
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbEnvMigrateStatusURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "failed", "error_message": "lock timeout"}},
})
err := runAppsShortcut(t, AppsDBEnvMigrate,
[]string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--as", "user"}, factory, stdout)
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Fatalf("got %T %v, want API/server_error typed error", err, err)
}
if !strings.Contains(p.Message, "lock timeout") {
t.Fatalf("Message = %q, want it to contain 'lock timeout'", p.Message)
}
if !strings.Contains(p.Hint, "+db-env-diff") {
t.Fatalf("Hint = %q, want the db-env-migrate recovery hint", p.Hint)
}
}
// TestAppsDBEnvMigrate_RequiresConfirmation 验证 high-risk-write 无 --yes 时被确认门拦截。
func TestAppsDBEnvMigrate_RequiresConfirmation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
// high-risk-write 无 --yes → 应被确认门拦截(非 0 退出)。
if err := runAppsShortcut(t, AppsDBEnvMigrate,
[]string{"+db-env-migrate", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected confirmation gate without --yes")
}
}
// ── recovery-diff ──
// TestAppsDBRecoveryDiff_RequiresTarget 验证缺少 --target 时报必填错误。
func TestAppsDBRecoveryDiff_RequiresTarget(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
[]string{"+db-recovery-diff", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected required --target error")
}
}
// TestAppsDBRecoveryDiff_DryRunNormalizesTarget 验证 dry-run 走 POST env_recovery 且 --target 被归一化为 RFC3339 UTC。
func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true {
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
}
if s, _ := a.Body["target"].(string); !strings.HasSuffix(s, "Z") {
t.Fatalf("target not normalized to RFC3339 UTC: %v", a.Body["target"])
}
}
// TestAppsDBRecoveryDiff_SuccessRendersChanges 验证 preview 成功后 pretty 渲染受影响表数、行增删与预估耗时。
func TestAppsDBRecoveryDiff_SuccessRendersChanges(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbRecoveryURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}},
})
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbRecoveryDiffURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"preview_status": "success", "tables_affected": 2, "estimated_seconds": 12,
"changes": []interface{}{
map[string]interface{}{"table": "orders", "inserted": 5, "deleted": 2},
map[string]interface{}{"table": "carts", "action": "restore_table"},
},
}},
})
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{"tables affected: 2", "orders: +5 rows, -2 rows", "carts: table will be restored", "estimated time: ~12s"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
}
// TestAppsDBRecoveryDiff_PreviewFailed 验证 preview_status=failed 时返回 API/server_error携带 message 与 PITR window hint。
func TestAppsDBRecoveryDiff_PreviewFailed(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbRecoveryURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}},
})
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbRecoveryDiffURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_status": "failed", "error_message": "snapshot expired"}},
})
err := runAppsShortcut(t, AppsDBRecoveryDiff,
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout)
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Fatalf("got %T %v, want API/server_error typed error", err, err)
}
if !strings.Contains(p.Message, "snapshot expired") {
t.Fatalf("Message = %q, want it to contain 'snapshot expired'", p.Message)
}
if !strings.Contains(p.Hint, "PITR window") {
t.Fatalf("Hint = %q, want the db-recovery recovery hint", p.Hint)
}
}
// ── recovery-apply ──
// TestAppsDBRecoveryApply_NoChangesShortCircuits 验证 status=no_changes 时短路输出"已是该状态",不再轮询。
func TestAppsDBRecoveryApply_NoChangesShortCircuits(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbRecoveryURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "no_changes"}},
})
if err := runAppsShortcut(t, AppsDBRecoveryApply,
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "No changes — database is already at this state.") {
t.Fatalf("expected no-changes short-circuit, got: %s", stdout.String())
}
}
// TestAppsDBRecoveryApply_AsyncPollSuccess 验证 running → 轮询 success 后 pretty 输出恢复完成及耗时。
func TestAppsDBRecoveryApply_AsyncPollSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbRecoveryURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "running"}},
})
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbRecoveryApplyURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "success", "restore_time_sec": 8}},
})
if err := runAppsShortcut(t, AppsDBRecoveryApply,
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "✓ Database restored to") || !strings.Contains(stdout.String(), "(8s elapsed)") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsDBRecoveryApply_RequiresConfirmation 验证无 --yes 时被确认门拦截。
func TestAppsDBRecoveryApply_RequiresConfirmation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBRecoveryApply,
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected confirmation gate without --yes")
}
}
// ── quota-get ──
// TestAppsDBQuotaGet_WithQuotaPretty 验证已对接配额时 pretty 渲染存储用量、百分比及 tables/views 数。
func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbQuotaURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"storage_used_bytes": 1048576, "storage_quota_bytes": 10485760, "usage_percent": 10.0,
"tables": 4, "views": 1,
}},
})
if err := runAppsShortcut(t, AppsDBQuotaGet,
[]string{"+db-quota-get", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{"Storage", "(10.0%)", "Tables", "4", "Views", "1"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
}
// 配额未对接storage_quota_bytes=0→ json 删 quota/usage_percent仅留已用量与 tables/views。
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbQuotaURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"storage_used_bytes": 2048, "storage_quota_bytes": 0, "tables": 2, "views": 0,
}},
})
if err := runAppsShortcut(t, AppsDBQuotaGet,
[]string{"+db-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if strings.Contains(got, "storage_quota_bytes") || strings.Contains(got, "usage_percent") {
t.Fatalf("quota fields should be omitted when not provisioned:\n%s", got)
}
if !strings.Contains(got, "storage_used_bytes") || !strings.Contains(got, "\"tables\"") {
t.Fatalf("expected used + tables retained:\n%s", got)
}
}
// TestProjectDbQuota_WhitelistsFields 验证 projectDbQuota 白名单投影:只保留 used/tables/views及配额已对接时的
// quota/usage_percent后端额外字段不透传。
func TestProjectDbQuota_WhitelistsFields(t *testing.T) {
out := projectDbQuota(map[string]interface{}{
"storage_used_bytes": 2048, "storage_quota_bytes": float64(0), "usage_percent": float64(0),
"tables": 2, "views": 1, "tenant_key": "leak", "internal_shard": "s1",
})
if _, ok := out["storage_quota_bytes"]; ok {
t.Errorf("zero quota should be omitted: %v", out)
}
if out["storage_used_bytes"] != 2048 || out["tables"] != 2 || out["views"] != 1 {
t.Errorf("whitelisted fields should be kept: %v", out)
}
for _, leaked := range []string{"tenant_key", "internal_shard"} {
if _, ok := out[leaked]; ok {
t.Errorf("non-whitelisted field %q must be dropped: %v", leaked, out)
}
}
out2 := projectDbQuota(map[string]interface{}{"storage_used_bytes": 2048, "storage_quota_bytes": float64(4096), "usage_percent": float64(50), "tables": 2})
if _, ok := out2["storage_quota_bytes"]; !ok {
t.Errorf("non-zero quota should be kept: %v", out2)
}
if _, ok := out2["usage_percent"]; !ok {
t.Errorf("usage_percent should be kept when quota>0: %v", out2)
}
}

View File

@@ -1,616 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsDBExecute executes SQL against a Miaoda app database.
//
// POST /apps/{app_id}/sql_commandsCLI 永远带 ?transactional=false 进入 DBA 模式
// (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON
//
// pretty 渲染 6 种形态:
// - 单 SELECT表格列间两空格、列对齐填充
// - 空 SELECT`(0 rows)`
// - 单 DML`✓ N row(s) <verb>`verb 跟 sql_typeINSERT→inserted/UPDATE→updated/DELETE→deleted
// - 单 DDL`✓ DDL executed`
// - 多语句全部成功:逐条 `Statement K: ✓ <summary>` + 末尾 `✓ N statements executed`
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
//
// 失败语义server 多语句失败仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
// 后升级成 typed errs.APIErrorCategoryAPI → exit 1避免 agent 误判 ok:true 假成功。诊断信息
// (第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地)写进 message+hint 文案errs.* 信封扁平、无
// detail 容器):失败在用户显式 BEGIN…COMMIT 事务内 → 整批回滚、前序未落库;否则前序语句已逐条
// commit、未回滚。rolled_back 语义由 inferRolledBack 按 BEGIN/COMMIT 计数推断。
//
// JSON成功路径按 SQL 类型归一化 `data`(不透传后端 result 字符串):
// - 单 SELECT → data 是行数组 `[{...}]`(空 → `[]`
// - 单 DML → data = `{command, rows_affected}`
// - 单 DDL → data = `{command}`
// - 多语句 → data = `[{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]`
//
// 字段裁剪用框架原生 --jq/-q。
//
// Risk: high-risk-write —— SQL 可含 DML/DDL框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。
//
// SQL 来源二选一:--sql内联文本或 - 读 stdin/ --file.sql 文件路径,受 CLI 相对路径约束)。
// --file 在 Validate 阶段读出内容、归一化到 --sql下游统一从 rctx.Str("sql") 取。
var AppsDBExecute = common.Shortcut{
Service: appsService,
Command: "+db-execute",
Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database",
Risk: "high-risk-write",
Tips: []string{
`Example: lark-cli apps +db-execute --app-id <app_id> --sql "SELECT * FROM orders LIMIT 10" --yes`,
`Example: lark-cli apps +db-execute --app-id <app_id> --env dev --file ./migration.sql --yes`,
"Tip: single SELECT returns data as a row array — filter with --jq, e.g. -q '.data[].id'",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
Input: []string{common.Stdin}},
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
{Name: "env", Default: "dev", Enum: []string{"dev", "online"}, Desc: "target db environment (default dev; use --env online for the online environment)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
sql := strings.TrimSpace(rctx.Str("sql"))
file := strings.TrimSpace(rctx.Str("file"))
if sql != "" && file != "" {
return output.ErrValidation("--sql and --file are mutually exclusive")
}
if file != "" {
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
if err != nil {
return output.ErrValidation("--file: %v", err)
}
// 归一化:把文件内容写回 --sql下游DryRun/Execute统一从 sql 取。
rctx.Cmd.Flags().Set("sql", string(data))
sql = strings.TrimSpace(string(data))
}
if sql == "" {
return output.ErrValidation("one of --sql or --file is required (use --sql - to read stdin)")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appSQLPath(appID)).
Desc("Execute SQL on Miaoda app database").
Params(buildDBSQLParams(rctx)).
Body(buildDBSQLBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
raw, err := rctx.CallAPITyped("POST", appSQLPath(appID),
buildDBSQLParams(rctx),
buildDBSQLBody(rctx))
if err != nil {
return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table <table>`; for day-to-day debugging target the dev database with `--env dev`")
}
// server `result: string` 内嵌结构化数组 —— CLI 解出来后按 SQL 类型归一化成 PRD 形态,
// 让 json/pretty 路径都基于同一份反序列化产物渲染。
stmts := parseSQLResult(common.GetString(raw, "result"))
// JSON data 形态(不再透传后端 result 字符串):
// - 单 SELECT → data 是行数组 [{...}](空 → []
// - 单 DML → data = {command, rows_affected}
// - 单 DDL → data = {command}
// - 多语句 → data = [{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]
// 字段裁剪走框架原生 --jq/-q不引入 miaoda 的 --json <fields>)。
// 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出直接报错
// (而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。
data := shapeSQLData(stmts)
// 多语句 / 单语句失败server 仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。
// 升级成 typed api_errorexit 非 0别让 agent 误判 ok:true 假成功。
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout人看再返回 errorenvelope→stderr
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
if rctx.Format == "pretty" {
renderSQLPretty(rctx.IO().Out, stmts)
}
return sqlStatementError(stmts, errIdx, errStmt)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderSQLPretty(w, stmts)
})
return nil
},
}
// shapeSQLData 把解析出的 statements 归一化成 PRD 约定的 JSON `data` 形态:
// - 无语句 → [](空数组)
// - 单条语句 → singleStatementJSONSELECT 是行数组、DML/DDL 是对象)
// - 多条语句 → []multiStatementElement每条统一成 {command,...} 对象SELECT 行放 rows
//
// 不再透传后端 result 字符串(旧形态 data.results[].data 是 JSON 字符串,对 agent 不友好)。
func shapeSQLData(stmts []map[string]interface{}) interface{} {
if len(stmts) == 0 {
return []interface{}{}
}
if len(stmts) == 1 {
return singleStatementJSON(stmts[0])
}
out := make([]interface{}, 0, len(stmts))
for _, s := range stmts {
out = append(out, multiStatementElement(s))
}
return out
}
// singleStatementJSON 单条语句的 PRD JSON 形态:
// - SELECT → 行数组(空 → []
// - DML → {command, rows_affected}
// - DDL / OK / 其它 → {command}
func singleStatementJSON(s map[string]interface{}) interface{} {
sqlType := common.GetString(s, "sql_type")
switch {
case sqlType == "SELECT":
return selectRows(s)
case isDMLType(sqlType):
return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])}
default:
return map[string]interface{}{"command": sqlType}
}
}
// multiStatementElement 多语句里单条的 PRD JSON 形态:与单条一致,但 SELECT 包成
// {command:"SELECT", rows:[...]}(避免数组里直接嵌套数组造成歧义)。
func multiStatementElement(s map[string]interface{}) map[string]interface{} {
sqlType := common.GetString(s, "sql_type")
switch {
case sqlType == "SELECT":
return map[string]interface{}{"command": "SELECT", "rows": selectRows(s)}
case isDMLType(sqlType):
return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])}
default:
return map[string]interface{}{"command": sqlType}
}
}
// selectRows 把 SELECT statement 的 data 字段(行 JSON 数组字符串)解析成行数组;
// 空 / 非法一律返回非 nil 的空数组(保证 JSON 序列化成 [] 而非 null
func selectRows(s map[string]interface{}) []map[string]interface{} {
dataJSON := strings.TrimSpace(common.GetString(s, "data"))
if dataJSON == "" || dataJSON == "null" {
return []map[string]interface{}{}
}
var rows []map[string]interface{}
if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil || rows == nil {
return []map[string]interface{}{}
}
return rows
}
// findErrorSentinel 在 statements 里找 ERROR 哨兵server 失败时追加在失败语句位置)。
// 返回失败语句下标0-based、该 ERROR statement、是否命中。
func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interface{}, bool) {
for i, s := range stmts {
if common.GetString(s, "sql_type") == "ERROR" {
return i, s, true
}
}
return 0, nil, false
}
// sqlStatementError 把 ERROR 哨兵升级成 typed errs.APIErrorCategoryAPI → exit 1
//
// 多语句失败的诊断信息——第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地——都写进
// message + hint 的人类可读文案errs.* 信封是扁平字段、不带结构化 detail 容器)。文案对齐
// miaoda-clisrc/cli/handlers/db/sql.ts、src/api/db/api.ts
// - message 末尾 "(at statement N of M)" 给出失败位置;
// - hint 由 inferRolledBack 推断(实测后端把 BEGIN/COMMIT 也作为 statement 返回):
// 失败仍在用户显式事务内 → 服务端整批回滚,用 miaoda 原句 "Transaction rolled back; no changes persisted."
// 否则前序语句已逐条 commit、未回滚flat 信封无逐句 breakdown故 hint 简述前序已落地 + 从失败处续跑)。
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
stmtNo := errIdx + 1 // 1-based 给人看
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))
var hint string
switch {
case inferRolledBack(stmts[:errIdx]):
hint = "Transaction rolled back; no changes persisted."
case errIdx > 0:
hint = fmt.Sprintf("Earlier statements were committed and not rolled back; fix statement %d and re-run the remaining statements.", stmtNo)
default:
hint = "No statements were applied; fix the SQL and re-run."
}
return errs.NewAPIError(errs.SubtypeServerError, "%s", fullMsg).WithCode(code).WithHint("%s", hint)
}
// inferRolledBack 推断失败时是否处于用户显式事务内(→ 服务端整批回滚)。
// 遍历已完成语句的 sql_typeBEGIN/START TRANSACTION +1COMMIT/ROLLBACK/END -1
// 结束 depth>0 说明事务还开着、已被服务端回滚。对齐 miaoda-cli inferRolledBack。
func inferRolledBack(completed []map[string]interface{}) bool {
depth := 0
for _, s := range completed {
switch strings.ToUpper(strings.TrimSpace(common.GetString(s, "sql_type"))) {
case "BEGIN", "START TRANSACTION", "START_TRANSACTION":
depth++
case "COMMIT", "ROLLBACK", "END":
if depth > 0 {
depth--
}
}
}
return depth > 0
}
// parseErrorSentinel 解析 ERROR 哨兵的 data`{code,message}` JSON返回数值 code 与 message。
// code 兼容 int / "k_dl_1300002" / 数字字符串多形态(复用 codeString解析失败回退 0 / 原文。
func parseErrorSentinel(data string) (int, string) {
if data == "" {
return 0, "(unknown error)"
}
var e struct {
Code interface{} `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(data), &e); err != nil {
return 0, data
}
code := 0
if cs := codeString(e.Code); cs != "" {
if n, convErr := strconv.Atoi(cs); convErr == nil {
code = n
}
}
if e.Message == "" {
return code, "(unknown error)"
}
return code, e.Message
}
// buildDBSQLParams 构造 sql 接口的 queryenv + 强制 transactional=falseDBA 模式)。
//
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"env": rctx.Str("env"),
"transactional": false,
}
}
// buildDBSQLBody 构造 sql 接口的 body仅 sql来源由 Validate 归一化到 --sql
func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"sql": rctx.Str("sql"),
}
}
// parseSQLResult 从 server result 字符串反序列化出 statements 数组,兼容两种 wire 形态:
//
// 1. 结构化形态:`[{"sql_type":"SELECT","data":"[...]","record_count":N}, ...]`
// —— 每条 statement 含 sql_type / data / record_count / affected_rows 元数据。
//
// 2. 字符串数组形态:`["[{...rows...}]", "", ...]`
// —— 每条 statement 一个字符串SELECT 是 rows JSON、DML/DDL 是空串;
// 无 sql_type 元数据CLI 端按内容形态推断SELECT vs OK
//
// 解析失败时返回单元素 fallback `{sql_type:"RAW", data:resultStr}`pretty 路径原样打。
func parseSQLResult(resultStr string) []map[string]interface{} {
if resultStr == "" {
return nil
}
// 形态 1结构化数组每元素是 object
var structured []map[string]interface{}
if err := json.Unmarshal([]byte(resultStr), &structured); err == nil && isStructuredResult(structured) {
return structured
}
// 形态 2字符串数组每元素是 rows JSON 或 ""
var legacy []string
if err := json.Unmarshal([]byte(resultStr), &legacy); err == nil {
out := make([]map[string]interface{}, 0, len(legacy))
for _, rowsJSON := range legacy {
out = append(out, normalizeLegacyStatement(rowsJSON))
}
return out
}
return []map[string]interface{}{{"sql_type": "RAW", "data": resultStr}}
}
// isStructuredResult 判断反序列化出来的 []map 是不是新形态:第一条元素含 sql_type 字段。
// 兼容场景:[]map 反序列化 legacy `[""]` 可能也能成(空 map用 sql_type 存在性区分。
func isStructuredResult(stmts []map[string]interface{}) bool {
if len(stmts) == 0 {
return false
}
_, ok := stmts[0]["sql_type"]
return ok
}
// normalizeLegacyStatement 把 legacy wire 一个字符串元素转成跟新形态一致的 map。
// 推断规则data 是非空 rows 数组 → sql_type=SELECT空串 / 空数组 → sql_type=OKDML/DDL 老 wire 不可分)。
func normalizeLegacyStatement(rowsJSON string) map[string]interface{} {
stmt := map[string]interface{}{
"sql_type": "OK",
"data": rowsJSON,
}
trimmed := strings.TrimSpace(rowsJSON)
if trimmed == "" || trimmed == "null" {
return stmt
}
var rows []interface{}
if err := json.Unmarshal([]byte(trimmed), &rows); err != nil {
// 非 JSON 数组(理论上 server 不会返这种),按原样保留 sql_type=OK
return stmt
}
// 是 JSON 数组 → 视作 SELECT含 record_count
stmt["sql_type"] = "SELECT"
stmt["record_count"] = float64(len(rows))
return stmt
}
// renderSQLPretty 按 statements 数量分单条 / 多条两种渲染路径。
func renderSQLPretty(w io.Writer, stmts []map[string]interface{}) {
if len(stmts) == 0 {
fmt.Fprintln(w, "(empty result)")
return
}
if len(stmts) == 1 {
renderSingleStatementPretty(w, stmts[0])
return
}
renderMultiStatementPretty(w, stmts)
}
// renderSingleStatementPretty 单条 statement pretty无 Statement header
func renderSingleStatementPretty(w io.Writer, s map[string]interface{}) {
sqlType := common.GetString(s, "sql_type")
switch {
case sqlType == "SELECT":
renderSelectRowsAsTable(w, common.GetString(s, "data"))
case sqlType == "ERROR":
// 单条就挂的极端场景:直接打 ERROR 行(跟多语句失败的最后一行格式一致)。
fmt.Fprintln(w, "✗ "+errorSummary(common.GetString(s, "data")))
case isDMLType(sqlType):
// 结构化 wire 下 INSERT / UPDATE / DELETE / MERGE✓ N row(s) <verb>
fmt.Fprintln(w, "✓ "+dmlSummary(sqlType, s["affected_rows"]))
case sqlType == "OK":
// legacy wire 下 DML / DDL 都映射成 OK老 wire 不带 sql_type 元数据,无法区分动词 / 行数)
fmt.Fprintln(w, "✓ ok")
default:
// 其余皆 DDL真机 boe 返细粒度动词 CREATE_TABLE / DROP_TABLE / ALTER_TABLE / TRUNCATE 等。
fmt.Fprintln(w, "✓ DDL executed")
}
}
// renderMultiStatementPretty 多条 statement pretty
// - 每条用 "Statement K: ✓ <summary>" / "Statement K: ✗ <error> [<code>]"
// - SELECT 用 "Statement K: SELECT (N row(s))" 头 + 紧跟表格
// - 末尾汇总:全部成功 "✓ N statements executed";遇 ERROR 哨兵打「前序语句已落地」提示
// DBA 模式不回滚),失败本身由 Execute 升级成 typed errorexit 非 0
func renderMultiStatementPretty(w io.Writer, stmts []map[string]interface{}) {
failedIdx := -1
successCount := 0
for i, s := range stmts {
sqlType := common.GetString(s, "sql_type")
idx := i + 1
switch {
case sqlType == "ERROR":
fmt.Fprintf(w, "Statement %d: ✗ %s\n", idx, errorSummary(common.GetString(s, "data")))
failedIdx = i
case sqlType == "SELECT":
rc := intOrZero(s["record_count"])
fmt.Fprintf(w, "Statement %d: SELECT (%d row%s)\n", idx, rc, plural(rc))
renderSelectRowsAsTable(w, common.GetString(s, "data"))
successCount++
case isDMLType(sqlType):
fmt.Fprintf(w, "Statement %d: ✓ %s\n", idx, dmlSummary(sqlType, s["affected_rows"]))
successCount++
case sqlType == "OK":
fmt.Fprintf(w, "Statement %d: ✓ ok\n", idx)
successCount++
default:
// DDL 族CREATE_TABLE / DROP_TABLE / ALTER_TABLE / TRUNCATE / CREATE_INDEX ...
fmt.Fprintf(w, "Statement %d: ✓ DDL executed\n", idx)
successCount++
}
if i < len(stmts)-1 {
fmt.Fprintln(w) // statements 间留空行
}
}
fmt.Fprintln(w)
if failedIdx >= 0 {
// CLI 永远传 transactional=false失败语句之前的语句已逐条 commit 落地、不会整批回滚——
// 如实告诉用户,避免整批重跑导致重复写入。
if successCount > 0 {
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it committed and not rolled back)\n",
failedIdx+1, successCount, plural(int64(successCount)))
} else {
fmt.Fprintf(w, "(statement %d failed; no statements applied)\n", failedIdx+1)
}
} else {
fmt.Fprintf(w, "✓ %d statements executed\n", successCount)
}
}
// renderSelectRowsAsTable 把 SELECT 的 datarows JSON 数组字符串)解析并渲染成对齐表格。
// 空结果输出 "(0 rows)"。
func renderSelectRowsAsTable(w io.Writer, dataJSON string) {
if dataJSON == "" || dataJSON == "[]" {
fmt.Fprintln(w, "(0 rows)")
return
}
var rows []map[string]interface{}
if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil {
// 数据不符合预期 schema —— 原样打 fallback。
fmt.Fprintln(w, dataJSON)
return
}
if len(rows) == 0 {
fmt.Fprintln(w, "(0 rows)")
return
}
headers := collectColumns(rows)
cells := make([][]string, 0, len(rows))
for _, row := range rows {
line := make([]string, 0, len(headers))
for _, h := range headers {
line = append(line, cellString(row[h]))
}
cells = append(cells, line)
}
renderAlignedTable(w, headers, cells)
}
// collectColumns 按首行字段顺序收集列名;首行 key 顺序由 encoding/json 反序列化决定map 无序),
// 排序后保证输出稳定。列顺序在示例里跟 SQL SELECT 顺序一致——但 Go encoding/json 反序列化丢列序,
// 这里按字典序保证可重现agent / 测试可稳定 assert。
func collectColumns(rows []map[string]interface{}) []string {
set := map[string]struct{}{}
for _, r := range rows {
for k := range r {
set[k] = struct{}{}
}
}
cols := make([]string, 0, len(set))
for k := range set {
cols = append(cols, k)
}
sort.Strings(cols)
return cols
}
// cellString 把任意 JSON value 转字符串显示null → 空串;非字符串/数字 → JSON 编码)。
func cellString(v interface{}) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case bool:
if x {
return "true"
}
return "false"
case float64:
// 整数值不输出小数id=101 而不是 101.000000)。
if x == float64(int64(x)) {
return fmt.Sprintf("%d", int64(x))
}
return fmt.Sprintf("%g", x)
}
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
// dmlSummary 把 sql_type + affected_rows 渲染成 "N row(s) <verb>" 字符串。
//
// 动词映射INSERT → inserted / UPDATE → updated / DELETE → deleted / MERGE → merged。
// 未知 sql_type 默认 "affected"。
func dmlSummary(sqlType string, affectedRows interface{}) string {
n := intOrZero(affectedRows)
verb := dmlVerb(sqlType)
return fmt.Sprintf("%d row%s %s", n, plural(n), verb)
}
// isDMLType 判断 sql_type 是否是行级 DML带 affected_rows 语义)。
// 真机 boe wireSELECT 走表格、INSERT/UPDATE/DELETE/MERGE 走行数摘要、其余CREATE_TABLE /
// DROP_TABLE / ALTER_TABLE / TRUNCATE / CREATE_INDEX ...)一律按 DDL 处理。
func isDMLType(sqlType string) bool {
switch strings.ToUpper(sqlType) {
case "INSERT", "UPDATE", "DELETE", "MERGE":
return true
}
return false
}
// dmlVerb 把 DML sql_type 映射成过去分词动词INSERT→inserted / UPDATE→updated / DELETE→deleted / MERGE→merged未知 → affected。
func dmlVerb(sqlType string) string {
switch strings.ToUpper(sqlType) {
case "INSERT":
return "inserted"
case "UPDATE":
return "updated"
case "DELETE":
return "deleted"
case "MERGE":
return "merged"
}
return "affected"
}
// plural 返回英文复数后缀n==1 时空串,否则 "s"。
func plural(n int64) string {
if n == 1 {
return ""
}
return "s"
}
// errorSummary 从 ERROR 哨兵的 data 字段({code, message} JSON解析出 "message [code]" 形态。
// 解析失败时回退到原文。
func errorSummary(data string) string {
if data == "" {
return "(unknown error)"
}
var e struct {
Code interface{} `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(data), &e); err != nil {
return data
}
codeStr := codeString(e.Code)
if codeStr != "" {
return fmt.Sprintf("%s [%s]", e.Message, codeStr)
}
return e.Message
}
// codeString 处理 code 字段在 wire 上可能是 int / "k_dl_1300015" / 数字字符串等多形态。
func codeString(c interface{}) string {
switch x := c.(type) {
case nil:
return ""
case string:
// "k_dl_1300015" → 抽 1300015纯数字保持原样。
if strings.HasPrefix(x, "k_dl_") {
return strings.TrimPrefix(x, "k_dl_")
}
return x
case float64:
return fmt.Sprintf("%d", int64(x))
}
return ""
}
// intOrZero 把 JSON number 转 int64nil / 类型不匹配返回 0。
func intOrZero(raw interface{}) int64 {
if n, ok := numericAsFloat(raw); ok {
return int64(n)
}
return 0
}

View File

@@ -1,980 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestAppsDBExecute_SingleSELECTJSONIsRowArray 断言单条 SELECT 的 JSON data 直接是行数组(不再透传 result 字符串)。
func TestAppsDBExecute_SingleSELECTJSONIsRowArray(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
// DBA 模式 result结构化数组 JSON 字符串
"result": `[{"sql_type":"SELECT","data":"[{\"id\":101,\"total_cents\":2500}]","record_count":1}]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
// PRD 单 SELECTdata 直接是行数组(不再是 data.results[].data 字符串)
var env struct {
Data []map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v\n%s", err, stdout.String())
}
if len(env.Data) != 1 {
t.Fatalf("data = %d rows (want 1)\n%s", len(env.Data), stdout.String())
}
if env.Data[0]["id"] != float64(101) || env.Data[0]["total_cents"] != float64(2500) {
t.Fatalf("data[0] = %v, want {id:101,total_cents:2500}", env.Data[0])
}
}
// TestAppsDBExecute_SingleDMLJSONShape 断言单条 DML 的 JSON data 形如 {command, rows_affected}。
func TestAppsDBExecute_SingleDMLJSONShape(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"INSERT","data":"","affected_rows":3}]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "insert", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
// PRD 单 DMLdata = {command, rows_affected}
var env struct {
Data struct {
Command string `json:"command"`
RowsAffected int `json:"rows_affected"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
if env.Data.Command != "INSERT" || env.Data.RowsAffected != 3 {
t.Fatalf("data = %+v, want {command:INSERT, rows_affected:3}", env.Data)
}
}
// TestAppsDBExecute_SingleDDLJSONShape 断言单条 DDL 的 JSON data 形如 {command}。
func TestAppsDBExecute_SingleDDLJSONShape(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"CREATE_TABLE","data":"[]"}]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "create", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
// PRD 单 DDLdata = {command}
var env struct {
Data struct {
Command string `json:"command"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
if env.Data.Command != "CREATE_TABLE" {
t.Fatalf("data.command = %q, want CREATE_TABLE", env.Data.Command)
}
}
// TestAppsDBExecute_MultiStatementJSONShape 断言多语句的 JSON data 是元素数组,且 SELECT 包成 {command:"SELECT", rows:[...]}。
func TestAppsDBExecute_MultiStatementJSONShape(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[` +
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
`{"sql_type":"SELECT","data":"[{\"id\":999}]","record_count":1}` +
`]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
// PRD 多语句data 是元素数组SELECT 包成 {command:"SELECT", rows:[...]}
var env struct {
Data []map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
if len(env.Data) != 2 {
t.Fatalf("data = %d elements (want 2)\n%s", len(env.Data), stdout.String())
}
if env.Data[0]["command"] != "INSERT" || env.Data[0]["rows_affected"] != float64(1) {
t.Fatalf("data[0] = %v, want {command:INSERT, rows_affected:1}", env.Data[0])
}
if env.Data[1]["command"] != "SELECT" {
t.Fatalf("data[1].command = %v, want SELECT", env.Data[1]["command"])
}
rows, ok := env.Data[1]["rows"].([]interface{})
if !ok || len(rows) != 1 {
t.Fatalf("data[1].rows = %v, want 1 row", env.Data[1]["rows"])
}
}
// TestAppsDBExecute_DryRunSendsTransactionalFalse 断言 dry-run 发出的请求是 POST、params 带 transactional=falseDBA 模式)且 transactional 不在 body 里。
func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--env", "dev", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/sql_commands" {
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
}
if env.API[0].Body["sql"] != "select 1" {
t.Fatalf("body.sql = %v", env.API[0].Body["sql"])
}
if env.API[0].Params["env"] != "dev" {
t.Fatalf("params.env = %v", env.API[0].Params["env"])
}
if env.API[0].Params["transactional"] != false {
t.Fatalf("params.transactional = %v (want false, CLI is DBA mode)", env.API[0].Params["transactional"])
}
if _, ok := env.API[0].Body["transactional"]; ok {
t.Fatalf("transactional should NOT be in body, got body=%v", env.API[0].Body)
}
}
// TestAppsDBExecute_RejectsEmptySQL 断言 --sql 全空白时校验报错(提示需要 --sql 或 --file
func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", " ", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--sql or --file") {
t.Fatalf("expected empty-sql error, got %v", err)
}
}
// --sql 与 --file 互斥
func TestAppsDBExecute_RejectsSQLAndFileTogether(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1", "--file", "x.sql", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutual-exclusion error, got %v", err)
}
}
// --file 读取相对路径 .sql 文件 → 内容进 body.sqldry-run 验证)
func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
dir := t.TempDir()
sqlPath := filepath.Join(dir, "m.sql")
if err := os.WriteFile(sqlPath, []byte("SELECT 42 AS answer;\n"), 0o600); err != nil {
t.Fatal(err)
}
// 切到临时目录使相对路径校验通过CLI 仅接受 cwd 内相对路径)。
// 用 os.Chdir + 还原而非 t.Chdir后者要 Go 1.24,本仓库 go.mod 为 1.23。
oldWD, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--app-id", "app_x", "--env", "dev", "--file", "m.sql", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
if env.API[0].Body["sql"] != "SELECT 42 AS answer;\n" {
t.Fatalf("body.sql = %v, want file content", env.API[0].Body["sql"])
}
}
// ============================================================================
// legacy wire 形态测试 —— BOE server 实测返这种 ["rows-json-string", ...]
// 形态而非 spec 里的 [{sql_type, data, ...}]CLI 端必须兼容。
// 输入用 BOE 真实抓包数据test_scripts/boe_e2e/run.log
// ============================================================================
// TestAppsDBExecute_LegacyWireSingleSelect 断言 legacy 字符串数组 wire 的单 SELECT 能正常渲染表格、不回退到 RAW。
func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) {
// BOE 实测SELECT 1 AS x → result: "[\"[{\\\"x\\\":1}]\"]"
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `["[{\"x\":1}]"]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1 AS x", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "x") {
t.Errorf("missing header 'x':\n%s", got)
}
if !strings.Contains(got, "1") {
t.Errorf("missing value row '1':\n%s", got)
}
// 不应回退到 RAW
if strings.Contains(got, "RAW") || strings.Contains(got, "[\\\"") {
t.Errorf("should not fall back to RAW or raw-string passthrough:\n%s", got)
}
}
// TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray 断言 legacy wire 的 SELECT 同样归一化成 PRD 行数组形态。
func TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray(t *testing.T) {
// 验证 legacy wire 的 SELECT 也归一化成 PRD 行数组形态data 直接是行)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `["[{\"x\":1}]"]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1 AS x", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data []map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
if len(env.Data) != 1 {
t.Fatalf("data length = %d, want 1; got: %v", len(env.Data), env.Data)
}
if env.Data[0]["x"] != float64(1) {
t.Fatalf("data[0].x = %v, want 1", env.Data[0]["x"])
}
}
// TestAppsDBExecute_LegacyWireMultiSelect 断言 legacy wire 多 SELECT 输出带 Statement N header 与末尾 "✓ N statements executed" 汇总。
func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) {
// BOE 实测SELECT 1; SELECT 2 → result: "[\"[{\\\"?column?\\\":1}]\",\"[{\\\"?column?\\\":2}]\"]"
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `["[{\"?column?\":1}]","[{\"?column?\":2}]"]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1; SELECT 2;", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// 多语句应有 Statement N: header
if !strings.Contains(got, "Statement 1: SELECT") || !strings.Contains(got, "Statement 2: SELECT") {
t.Errorf("missing Statement headers:\n%s", got)
}
// 末尾应有 ✓ N statements executed
if !strings.Contains(got, "✓ 2 statements executed") {
t.Errorf("missing summary line:\n%s", got)
}
}
// TestAppsDBExecute_LegacyWireDDLEmptyResult 断言 result 为空字符串时legacy DDLpretty 输出 "(empty result)"。
func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) {
// BOE 实测CREATE TABLE → result: "" (空字符串,无 rows
// 老 wire 不区分 DDL/DML/无返回,统一标 "ok"
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": ``, // 空字符串
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "CREATE TABLE foo (id INT)", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// result="" 触发 parseSQLResult 返 nil → renderSQLPretty 输出 "(empty result)"
if !strings.Contains(got, "(empty result)") {
t.Errorf("expected '(empty result)' for empty result string, got:\n%s", got)
}
}
// TestAppsDBExecute_LegacyWireMultiSelectWithRealTable 断言含 CJK / uuid / int 字段的真实表行能正确显示在 pretty 表格里。
func TestAppsDBExecute_LegacyWireMultiSelectWithRealTable(t *testing.T) {
// BOE 实测真实表抓包course 表第一行):复杂 JSON 含 CJK / timestamp / uuid 字段
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `["[{\"id\":\"abc-123\",\"title\":\"高效沟通\",\"capacity\":30}]"]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT id,title,capacity FROM course LIMIT 1", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// 验证 CJK / uuid / int 都能正确显示在表格里
for _, want := range []string{"id", "title", "capacity", "abc-123", "高效沟通", "30"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in pretty output:\n%s", want, got)
}
}
}
// pretty 单 SELECT表格输出列间两空格无 Statement header。
func TestAppsDBExecute_PrettySingleSelectTable(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"SELECT","data":"[{\"id\":101,\"total_cents\":2500},{\"id\":102,\"total_cents\":1800}]","record_count":2}]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if strings.Contains(got, "Statement 1:") {
t.Errorf("single statement pretty should NOT have Statement header\noutput:\n%s", got)
}
// 列按字典序排序id / total_cents
if !strings.Contains(got, "id total_cents") {
t.Errorf("missing header row\noutput:\n%s", got)
}
if !strings.Contains(got, "101 2500") || !strings.Contains(got, "102 1800") {
t.Errorf("missing data rows\noutput:\n%s", got)
}
}
// TestAppsDBExecute_PrettyEmptySelect 断言空 SELECT 的 pretty 输出为 "(0 rows)"。
func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"SELECT","data":"[]","record_count":0}]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "(0 rows)") {
t.Fatalf("empty SELECT should print (0 rows), got:\n%s", stdout.String())
}
}
// TestAppsDBExecute_PrettySingleDMLAndDDL 断言单条 DML 渲染 "✓ N row(s) <verb>"、各类 DDL含细粒度动词渲染 "✓ DDL executed"。
func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) {
cases := []struct {
name string
result string
wantStr string
}{
{"INSERT_1_row", `[{"sql_type":"INSERT","data":"","affected_rows":1}]`, "✓ 1 row inserted"},
{"UPDATE_5_rows", `[{"sql_type":"UPDATE","data":"","affected_rows":5}]`, "✓ 5 rows updated"},
{"DELETE_0_rows", `[{"sql_type":"DELETE","data":"","affected_rows":0}]`, "✓ 0 rows deleted"},
{"DDL", `[{"sql_type":"DDL","data":"","affected_rows":0}]`, "✓ DDL executed"},
// 真机 boe 实测DDL 的 sql_type 是细粒度动词CREATE_TABLE / DROP_TABLE / ALTER_TABLE...
// data 是 "[]"、无 affected_rows。必须识别为 DDL而不是落到 dmlSummary 渲染成 "0 rows affected"。
{"CREATE_TABLE", `[{"sql_type":"CREATE_TABLE","data":"[]"}]`, "✓ DDL executed"},
{"DROP_TABLE", `[{"sql_type":"DROP_TABLE","data":"[]"}]`, "✓ DDL executed"},
{"ALTER_TABLE", `[{"sql_type":"ALTER_TABLE","data":"[]"}]`, "✓ DDL executed"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"result": c.result}},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), c.wantStr) {
t.Errorf("want %q\ngot:\n%s", c.wantStr, stdout.String())
}
})
}
}
// TestAppsDBExecute_PrettyMultiStatementsAllSuccess 断言多语句全成功时逐条 Statement 摘要 + 末尾 "✓ N statements executed"。
func TestAppsDBExecute_PrettyMultiStatementsAllSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[` +
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
`{"sql_type":"UPDATE","data":"","affected_rows":1},` +
`{"sql_type":"SELECT","data":"[{\"id\":999}]","record_count":1}` +
`]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, line := range []string{
"Statement 1: ✓ 1 row inserted",
"Statement 2: ✓ 1 row updated",
"Statement 3: SELECT (1 row)",
"✓ 3 statements executed",
} {
if !strings.Contains(got, line) {
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
}
}
}
// TestAppsDBExecute_PrettyMultiStatementsDDL 钉住真机 boe 多语句 DDL 的 wire
// CREATE_TABLE / DROP_TABLEdata="[]"、无 affected_rows须渲染成 "✓ DDL executed"
// 不能落到 dmlSummary 变成 "0 rows affected"。
func TestAppsDBExecute_PrettyMultiStatementsDDL(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"CREATE_TABLE","data":"[]"},{"sql_type":"DROP_TABLE","data":"[]"}]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, line := range []string{
"Statement 1: ✓ DDL executed",
"Statement 2: ✓ DDL executed",
"✓ 2 statements executed",
} {
if !strings.Contains(got, line) {
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
}
}
if strings.Contains(got, "rows affected") {
t.Errorf("DDL must not render as 'rows affected'\nfull:\n%s", got)
}
}
// TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel 断言多语句部分失败时 pretty 仍打逐条 ✓/✗ 摘要、声明前序已 commit 未回滚,且返回 typed error、不打成功汇总。
func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[` +
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
`{"sql_type":"ERROR","data":"{\"code\":1300015,\"message\":\"syntax error at or near 'SELEC'\"}"}` +
`]`,
},
},
})
// pretty 失败路径:逐条 ✓/✗ 摘要照打到 stdout人看同时返回 typed errorexit 非 0
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("pretty multi-statement failure must still return a typed error; stdout:\n%s", stdout.String())
}
got := stdout.String()
for _, line := range []string{
"Statement 1: ✓ 1 row inserted",
"Statement 2: ✗ syntax error at or near 'SELEC' [1300015]",
} {
if !strings.Contains(got, line) {
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
}
}
// 非事务transactional=false前序语句已逐条 commit 落地须如实说明「committed and not rolled back」
// 绝不能误报整批回滚。
if !strings.Contains(got, "committed and not rolled back") {
t.Errorf("non-tx failure must state prior statements committed & not rolled back; got:\n%s", got)
}
if strings.Contains(got, "statements executed") {
t.Errorf("failed run should NOT print success summary; got:\n%s", got)
}
}
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed errs.APIError」
// json 默认不再打 ok:true 假成功,而是返回 typed errs.* 错误type=api / subtype=server_error、
// exit=1。失败位置在 message 的 "(at statement N of M)",前序是否落地/是否回滚写在 hint。
// 本例无 BEGIN → 前序逐条 commit、未回滚hint 含 "committed and not rolled back")。
func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[` +
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
`{"sql_type":"ERROR","data":"{\"code\":\"k_dl_1300002\",\"message\":\"duplicate key value violates unique constraint\"}"}` +
`]`,
},
},
})
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String())
}
// json 失败路径不得打成功 envelope。
if strings.Contains(stdout.String(), `"ok": true`) {
t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
}
if p.Code != 1300002 {
t.Errorf("code = %d, want 1300002", p.Code)
}
if !strings.Contains(p.Message, "(at statement 2 of 2)") {
t.Errorf("message missing statement locator: %q", p.Message)
}
// 无 BEGIN → 前序逐条 commit、未回滚语义写在 hint。
if !strings.Contains(p.Hint, "committed and not rolled back") {
t.Errorf("hint should state prior statements committed & not rolled back: %q", p.Hint)
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
}
}
// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败server 也返 code:0 + ERROR 哨兵)
// 同样升级成 typed errorstatement_index=0、completed 空、message 标注 (at statement 1 of 1)。
func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error at or near 'SELEC'\"}"}]`,
},
},
})
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
}
if !strings.Contains(p.Message, "(at statement 1 of 1)") {
t.Errorf("message missing locator: %q", p.Message)
}
// 第一条就失败、无落地 的语义写在 hint。
if !strings.Contains(p.Hint, "No statements were applied") {
t.Errorf("hint should state nothing applied: %q", p.Hint)
}
}
// TestAppsDBExecute_TransactionFailureRolledBack 钉死「显式事务内失败 → 整批回滚」:
// 实测后端把 BEGIN 也作为 statement 返回completed 含未配对 BEGIN → inferRolledBack 判定回滚。
// 回滚语义现写在 hintmiaoda 原句 "Transaction rolled back; no changes persisted."),失败位置在 message。
func TestAppsDBExecute_TransactionFailureRolledBack(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
// BOE 实测 wireBEGIN; CREATE; INSERT(ok); INSERT(dup→ERROR)
"result": `[` +
`{"sql_type":"BEGIN","data":"[]"},` +
`{"sql_type":"CREATE_TABLE","data":"[]"},` +
`{"sql_type":"INSERT","data":"[{\"rowCount\":1}]","affected_rows":1},` +
`{"sql_type":"ERROR","data":"{\"code\":\"k_dl_1300002\",\"message\":\"duplicate key value violates unique constraint\"}"}` +
`]`,
},
},
})
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("transaction failure must return a typed error; stdout:\n%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
}
if !strings.Contains(p.Message, "(at statement 4 of 4)") {
t.Errorf("message missing statement locator: %q", p.Message)
}
// 事务整批回滚 / 前序未落库 的语义写在 hintmiaoda 原句)。
if !strings.Contains(p.Hint, "Transaction rolled back; no changes persisted.") {
t.Errorf("hint should state transaction rolled back & nothing persisted: %q", p.Hint)
}
}
// TestInferRolledBack_Cases 断言 inferRolledBack 按 BEGIN/COMMIT/ROLLBACK 计数判定失败时事务是否仍开着(即整批回滚)。
func TestInferRolledBack_Cases(t *testing.T) {
stmt := func(t string) map[string]interface{} { return map[string]interface{}{"sql_type": t} }
cases := []struct {
name string
completed []map[string]interface{}
want bool
}{
{"empty", nil, false},
{"autocommit single", []map[string]interface{}{stmt("INSERT")}, false},
{"open tx (unmatched BEGIN)", []map[string]interface{}{stmt("BEGIN"), stmt("CREATE_TABLE"), stmt("INSERT")}, true},
{"closed tx (BEGIN+COMMIT)", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("COMMIT")}, false},
{"reopened tx", []map[string]interface{}{stmt("BEGIN"), stmt("COMMIT"), stmt("BEGIN"), stmt("INSERT")}, true},
{"rollback closes tx", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("ROLLBACK")}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := inferRolledBack(c.completed); got != c.want {
t.Errorf("inferRolledBack(%s) = %v, want %v", c.name, got, c.want)
}
})
}
}
// TestCellString_AllKinds 断言 cellString 对 nil/string/bool/整数/小数/对象各类型的字符串化结果。
func TestCellString_AllKinds(t *testing.T) {
cases := []struct {
name string
in interface{}
want string
}{
{"nil", nil, ""},
{"string", "hello", "hello"},
{"bool true", true, "true"},
{"bool false", false, "false"},
{"int float", float64(101), "101"},
{"fractional", float64(1.25), "1.25"},
{"object", map[string]interface{}{"a": float64(1)}, `{"a":1}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := cellString(c.in); got != c.want {
t.Errorf("cellString(%v)=%q want %q", c.in, got, c.want)
}
})
}
}
// TestCodeString_Forms 断言 codeString 处理 nil / "k_dl_xxx" / 纯数字串 / float64 / 不支持类型各形态。
func TestCodeString_Forms(t *testing.T) {
cases := []struct {
name string
in interface{}
want string
}{
{"nil", nil, ""},
{"k_dl prefix", "k_dl_1300015", "1300015"},
{"plain string", "1300015", "1300015"},
{"float64", float64(42), "42"},
{"unsupported", []int{1}, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := codeString(c.in); got != c.want {
t.Errorf("codeString(%v)=%q want %q", c.in, got, c.want)
}
})
}
}
// TestDmlVerb_AllVerbs 断言 dmlVerb 对 INSERT/UPDATE/DELETE/MERGE 的动词映射(大小写不敏感),非 DML 返回 affected。
func TestDmlVerb_AllVerbs(t *testing.T) {
cases := map[string]string{
"INSERT": "inserted",
"update": "updated",
"DELETE": "deleted",
"Merge": "merged",
"CREATE_TABLE": "affected",
}
for in, want := range cases {
if got := dmlVerb(in); got != want {
t.Errorf("dmlVerb(%q)=%q want %q", in, got, want)
}
}
}
// TestIntOrZero_Cases 断言 intOrZero 对 JSON number 取整、对非数字 / nil 返回 0。
func TestIntOrZero_Cases(t *testing.T) {
if got := intOrZero(float64(5)); got != 5 {
t.Errorf("intOrZero(5)=%d want 5", got)
}
if got := intOrZero("x"); got != 0 {
t.Errorf("intOrZero(non-numeric)=%d want 0", got)
}
if got := intOrZero(nil); got != 0 {
t.Errorf("intOrZero(nil)=%d want 0", got)
}
}
// TestErrorSummary_Cases 断言 errorSummary 对空 / 非法 JSON / 带 code / 无 code 各情形生成 "message [code]" 文案。
func TestErrorSummary_Cases(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"empty", "", "(unknown error)"},
{"malformed json", "not json", "not json"},
{"with code", `{"code":"k_dl_1300015","message":"boom"}`, "boom [1300015]"},
{"no code", `{"message":"plain"}`, "plain"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := errorSummary(c.in); got != c.want {
t.Errorf("errorSummary(%q)=%q want %q", c.in, got, c.want)
}
})
}
}
// TestParseErrorSentinel_Cases 断言 parseErrorSentinel 解析 ERROR 哨兵 data 得到数值 code 与 message含空 / 非法 / 空 message 回退)。
func TestParseErrorSentinel_Cases(t *testing.T) {
cases := []struct {
name, in string
wantCode int
wantMsg string
}{
{"empty", "", 0, "(unknown error)"},
{"malformed", "xyz", 0, "xyz"},
{"code+msg", `{"code":"1300015","message":"boom"}`, 1300015, "boom"},
{"empty msg", `{"code":"1300015","message":""}`, 1300015, "(unknown error)"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
code, msg := parseErrorSentinel(c.in)
if code != c.wantCode || msg != c.wantMsg {
t.Errorf("parseErrorSentinel(%q)=%d,%q want %d,%q", c.in, code, msg, c.wantCode, c.wantMsg)
}
})
}
}
// TestIsStructuredResult_Cases 断言 isStructuredResult 仅在首元素含 sql_type 时判为新结构化形态。
func TestIsStructuredResult_Cases(t *testing.T) {
if !isStructuredResult([]map[string]interface{}{{"sql_type": "SELECT"}}) {
t.Error("expected structured=true when sql_type present")
}
if isStructuredResult([]map[string]interface{}{{}}) {
t.Error("expected structured=false when sql_type absent")
}
if isStructuredResult(nil) {
t.Error("expected structured=false for empty")
}
}
// TestNormalizeLegacyStatement_Cases 断言 normalizeLegacyStatement 把空 / null / 非 JSON 标为 OK、把 rows 数组标为 SELECT 并带 record_count。
func TestNormalizeLegacyStatement_Cases(t *testing.T) {
t.Run("empty -> OK", func(t *testing.T) {
got := normalizeLegacyStatement("")
if got["sql_type"] != "OK" {
t.Errorf("got sql_type=%v want OK", got["sql_type"])
}
})
t.Run("null -> OK", func(t *testing.T) {
got := normalizeLegacyStatement("null")
if got["sql_type"] != "OK" {
t.Errorf("got sql_type=%v want OK", got["sql_type"])
}
})
t.Run("rows -> SELECT", func(t *testing.T) {
got := normalizeLegacyStatement(`[{"id":1}]`)
if got["sql_type"] != "SELECT" {
t.Errorf("got sql_type=%v want SELECT", got["sql_type"])
}
if got["record_count"] != float64(1) {
t.Errorf("got record_count=%v want 1", got["record_count"])
}
})
t.Run("non-json kept as OK", func(t *testing.T) {
got := normalizeLegacyStatement(`notjson`)
if got["sql_type"] != "OK" {
t.Errorf("got sql_type=%v want OK", got["sql_type"])
}
})
}
// TestCellString_MarshalFallback 断言 cellString 对 json.Marshal 拒绝的类型(如 complex回退到 fmt %v。
func TestCellString_MarshalFallback(t *testing.T) {
// complex128 is not switch-handled and json.Marshal rejects it →
// falls back to fmt.Sprintf("%v", v), which is deterministic for complex.
if got := cellString(complex(1, 2)); got != "(1+2i)" {
t.Errorf("cellString(complex)=%q want (1+2i)", got)
}
}
// TestRenderSingleStatementPretty_Branches 断言 renderSingleStatementPretty 对 SELECT/ERROR/DML/legacy OK/DDL 各分支的输出。
func TestRenderSingleStatementPretty_Branches(t *testing.T) {
cases := []struct {
name string
stmt map[string]interface{}
substr string
}{
{"select empty", map[string]interface{}{"sql_type": "SELECT", "data": "[]"}, "(0 rows)"},
{"error", map[string]interface{}{"sql_type": "ERROR", "data": `{"message":"boom"}`}, "✗ boom"},
{"dml insert", map[string]interface{}{"sql_type": "INSERT", "affected_rows": float64(3)}, "✓ 3 rows inserted"},
{"legacy ok", map[string]interface{}{"sql_type": "OK"}, "✓ ok"},
{"ddl default", map[string]interface{}{"sql_type": "CREATE_TABLE"}, "✓ DDL executed"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var b strings.Builder
renderSingleStatementPretty(&b, c.stmt)
if !strings.Contains(b.String(), c.substr) {
t.Errorf("output %q does not contain %q", b.String(), c.substr)
}
})
}
}
// TestRenderSelectRowsAsTable_Branches 断言 renderSelectRowsAsTable 对空串 / 空数组 / 非法 JSON 回退 / 正常 rows 各分支的输出。
func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
cases := []struct {
name string
data string
substr string
}{
{"empty string", "", "(0 rows)"},
{"empty array", "[]", "(0 rows)"},
{"malformed fallback", "{bad", "{bad"},
{"rows", `[{"id":1}]`, "id"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var b strings.Builder
renderSelectRowsAsTable(&b, c.data)
if !strings.Contains(b.String(), c.substr) {
t.Errorf("output %q does not contain %q", b.String(), c.substr)
}
})
}
}

View File

@@ -1,100 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsDBQuotaGet reports an app's database storage usage and object counts.
//
// GET /apps/{app_id}/db/quota。storage_quota_bytes / usage_percent 在配额未对接(=0
// 不输出(与 +file-quota-get 一致tables / views 始终输出。
var AppsDBQuotaGet = common.Shortcut{
Service: appsService,
Command: "+db-quota-get",
Description: "Get an app's database storage usage",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-quota-get --app-id <app_id>",
"Example: lark-cli apps +db-quota-get --app-id <app_id> --env dev",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appDbQuotaPath(appID)).
Desc("Get Miaoda app database storage usage").
Params(map[string]interface{}{"env": rctx.Str("env")})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": rctx.Str("env")}, nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := projectDbQuota(data)
rctx.OutFormat(out, nil, func(w io.Writer) {
renderDbQuotaPretty(w, out)
})
return nil
},
}
// projectDbQuota 白名单投影 db quota 字段:只保留 storage_used_bytes / tables / views
// 配额已对接时再加 storage_quota_bytes / usage_percent。不透传后端其它字段避免无用字段消耗上下文。
func projectDbQuota(data map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{"storage_used_bytes": data["storage_used_bytes"]}
for _, k := range []string{"tables", "views"} {
if v, ok := data[k]; ok {
out[k] = v
}
}
// 配额未对接storage_quota_bytes=0/缺失)时不输出 quota / usage_percent。
if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 {
out["storage_quota_bytes"] = data["storage_quota_bytes"]
if v, ok := data["usage_percent"]; ok {
out["usage_percent"] = v
}
}
return out
}
// renderDbQuotaPretty 打 Storage已用 / 配额 (百分比))与 Tables / Views 行(标签对齐 miaoda-cli
func renderDbQuotaPretty(w io.Writer, data map[string]interface{}) {
used := humanBytes(data["storage_used_bytes"])
usage := used
if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 {
pct := ""
if p, ok := numericAsFloat(data["usage_percent"]); ok {
pct = fmt.Sprintf(" (%.1f%%)", p)
}
usage = fmt.Sprintf("%s / %s%s", used, humanBytes(data["storage_quota_bytes"]), pct)
}
pairs := [][2]string{{"Storage", usage}}
if f, ok := numericAsFloat(data["tables"]); ok {
pairs = append(pairs, [2]string{"Tables", fmt.Sprintf("%d", int64(f))})
}
if f, ok := numericAsFloat(data["views"]); ok {
pairs = append(pairs, [2]string{"Views", fmt.Sprintf("%d", int64(f))})
}
renderKeyValuePairs(w, pairs)
}

View File

@@ -1,267 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const dbRecoveryHint = "PITR window is up to 7 days back, limited by your last `+db-env-migrate`; pass --target as a time (e.g. 2h / 2026-04-15 / 2026-04-15T10:00:00Z)"
// AppsDBRecoveryDiff 预览把数据库恢复到某个时间点会带来的变更PITR diff不落地
//
// POST /apps/{app_id}/db/env_recoverybody {target, dry_run:true} → preview_request_id
// 轮询 env_recovery_diff_status 至终态,返回受影响表与行数变化。预览也需 spark:app:write scope。
var AppsDBRecoveryDiff = common.Shortcut{
Service: appsService,
Command: "+db-recovery-diff",
Description: "Preview restoring the database to a point in time (PITR diff)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-recovery-diff --app-id <app_id> --target 2h",
"Apply with +db-recovery-apply --target <same> --yes.",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return normalizeTimeFlags(rctx, "target")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
target := rctx.Str("target")
preview, err := runRecoveryPreview(rctx, appID, target)
if err != nil {
return err
}
out := recoveryDiffOutput(target, preview)
rctx.OutFormat(out, nil, func(w io.Writer) {
renderRecoveryDiff(w, target, out)
})
return nil
},
}
// AppsDBRecoveryApply 把数据库恢复到某个时间点覆盖当前数据异步CLI 轮询至完成)。
//
// POST /apps/{app_id}/db/env_recoverybody {target, dry_run:false};目标=当前态时短路 no_changes
// 否则轮询 env_recovery_apply_status 至 success。high-risk-write。
var AppsDBRecoveryApply = common.Shortcut{
Service: appsService,
Command: "+db-recovery-apply",
Description: "Restore the database to a point in time (overwrites current data, irreversible)",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +db-recovery-apply --app-id <app_id> --target 2026-04-15T10:00:00Z --yes",
"Preview first with +db-recovery-diff.",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return normalizeTimeFlags(rctx, "target")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
target := rctx.Str("target")
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
if err != nil {
return withAppsHint(err, dbRecoveryHint)
}
// 目标=当前态 → 后端短路 no_changes不轮询。
if strings.ToLower(common.GetString(submit, "status")) == "no_changes" {
stop()
out := map[string]interface{}{"status": "no_changes", "target": target}
rctx.OutFormat(out, nil, func(w io.Writer) {
io.WriteString(w, "No changes — database is already at this state.\n")
})
return nil
}
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 30*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "status")) {
case "success", "restored", "ready":
return true, nil
case "failed":
msg := common.GetString(d, "error_message")
if msg == "" {
msg = fmt.Sprintf("recovery to %s failed", target)
}
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
}
return false, nil
})
if perr != nil {
return perr
}
stop()
out := map[string]interface{}{"status": "restored", "target": target}
if n := intFromAny(final["restore_time_sec"]); n > 0 {
out["restore_time_sec"] = n
}
rctx.OutFormat(out, nil, func(w io.Writer) {
if n, ok := out["restore_time_sec"].(int); ok {
fmt.Fprintf(w, "✓ Database restored to %s (%ds elapsed)\n", target, n)
} else {
fmt.Fprintf(w, "✓ Database restored to %s\n", target)
}
})
return nil
},
}
// runRecoveryPreview 触发 PITR 预览dry_run=true拿 preview_request_id轮询 diff_status 至终态。
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
if err != nil {
return nil, withAppsHint(err, dbRecoveryHint)
}
prid := common.GetString(submit, "preview_request_id")
if prid == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id")
}
return pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "preview_status")) {
case "success":
return true, nil
case "failed":
msg := common.GetString(d, "error_message")
if msg == "" {
msg = "recovery preview failed"
}
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
}
return false, nil
})
}
type recoveryChange struct {
Table string `json:"table"`
Inserted interface{} `json:"inserted,omitempty"`
Deleted interface{} `json:"deleted,omitempty"`
Action string `json:"action,omitempty"`
DroppedAt string `json:"dropped_at,omitempty"`
}
// recoveryDiffOutput 组装 diff 输出target / tables_affected / changes[] / estimated_seconds。
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
arr, _ := preview["changes"].([]interface{})
changes := make([]recoveryChange, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
changes = append(changes, recoveryChange{
Table: common.GetString(m, "table"),
Inserted: m["inserted"],
Deleted: m["deleted"],
Action: common.GetString(m, "action"),
DroppedAt: common.GetString(m, "dropped_at"),
})
}
tablesAffected := intFromAny(preview["tables_affected"])
if tablesAffected == 0 {
tablesAffected = len(changes)
}
est := intFromAny(preview["estimated_seconds"])
if est == 0 {
est = 30 // PRD 兜底
}
return map[string]interface{}{
"target": target, "tables_affected": tablesAffected,
"changes": changes, "estimated_seconds": est,
}
}
// renderRecoveryDiff 渲染 PITR 恢复预览:受影响表数、逐表变化描述及预估耗时;无变更打提示。
func renderRecoveryDiff(w io.Writer, target string, out map[string]interface{}) {
changes, _ := out["changes"].([]recoveryChange)
if len(changes) == 0 {
io.WriteString(w, "No changes — database is already at this state.\n")
return
}
fmt.Fprintf(w, "Recovery preview (→ %s):\n\n", target)
fmt.Fprintf(w, " tables affected: %d\n", intFromAny(out["tables_affected"]))
for _, c := range changes {
fmt.Fprintf(w, " %s: %s\n", c.Table, describeRecoveryChange(c))
}
fmt.Fprintf(w, "\n estimated time: ~%ds\n", intFromAny(out["estimated_seconds"]))
}
// describeRecoveryChangeschema 动作 或 数据行变化二选一(无 modified对齐设计
func describeRecoveryChange(c recoveryChange) string {
switch c.Action {
case "restore_table":
return "table will be restored"
case "drop_table":
return "table will be dropped"
case "alter_table":
return "table will be altered"
case "unavailable":
if c.DroppedAt != "" {
return "diff unavailable: " + c.DroppedAt
}
return "diff unavailable"
}
parts := make([]string, 0, 2)
if n := intFromAny(c.Inserted); n != 0 {
parts = append(parts, fmt.Sprintf("+%d rows", n))
}
if n := intFromAny(c.Deleted); n != 0 {
parts = append(parts, fmt.Sprintf("-%d rows", n))
}
if len(parts) == 0 {
return "no changes"
}
return strings.Join(parts, ", ")
}

View File

@@ -1,87 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const dbTableGetHint = "verify --app-id and --table are correct; list tables with `lark-cli apps +db-table-list --app-id <app_id>`; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
// AppsDBTableGet gets one table's structure (动词对齐 +db-table-list)。
//
// GET /apps/{app_id}/tables/{table_name}。
//
// `--format` 同时驱动 CLI 渲染和 server 请求形态:
// - `--format json`(默认)/ table / ndjson / csvCLI 不传 format queryresponse 含结构化
// columns / indexes / constraints / statsenvelope 化输出。
// - `--format pretty`CLI 给 server 带 ?format=ddlresponse 含 ddl 字符串stdout 直接打
// ddl 内容(无 envelope / 无表格包装)。
var AppsDBTableGet = common.Shortcut{
Service: appsService,
Command: "+db-table-get",
Description: "Get a table's structure: columns, indexes and constraints",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-table-get --app-id <app_id> --table <table>",
"Tip: filter fields with --jq (json format), e.g. -q '.data.columns[].name'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table name", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("table")) == "" {
return output.ErrValidation("--table is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appTablePath(appID, strings.TrimSpace(rctx.Str("table")))).
Desc("Get Miaoda app db table schema").
Params(buildDBTableGetParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
path := appTablePath(appID, strings.TrimSpace(rctx.Str("table")))
data, err := rctx.CallAPITyped("GET", path, buildDBTableGetParams(rctx), nil)
if err != nil {
return withAppsHint(err, dbTableGetHint)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
// pretty 模式stdout 直接打 ddl 文本(无 trailing newline由 server 返回的字符串决定)。
io.WriteString(w, common.GetString(data, "ddl"))
})
return nil
},
}
// buildDBTableGetParams 构造 schema 接口的 query。
//
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl要求返 CREATE 语句文本;
// 其他 format含默认 json不传该参数让 server 返默认结构化字段。
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{"env": rctx.Str("env")}
if rctx.Format == "pretty" {
params["format"] = "ddl"
}
return params
}

View File

@@ -1,131 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsDBTableGet_DefaultJSONReturnsStructuredFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/tables/orders",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"name": "orders",
"description": "订单表",
"columns": []interface{}{
map[string]interface{}{
"name": "id", "data_type": "int8",
"is_primary_key": true, "is_unique": true,
"is_allow_null": false, "default_value": "",
},
},
"indexes": []interface{}{
map[string]interface{}{"name": "orders_pkey", "type": "btree", "columns": []interface{}{"id"}, "definition": "..."},
},
"constraints": []interface{}{
map[string]interface{}{"type": "primary_key", "name": "orders_pkey", "columns": []interface{}{"id"}},
},
"estimated_row_count": 1200,
"size_bytes": 81920,
},
},
})
if err := runAppsShortcut(t, AppsDBTableGet,
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"name": "orders"`) {
t.Fatalf("stdout missing schema name: %s", got)
}
}
// --format pretty 是触发 DDL 模式的唯一开关。
// 用 --format json + --dry-run 走 JSON envelope 路径方便 parse但 query 形态由代码内部
// 根据 rctx.Format 决定 —— 这里我们直接传 --format pretty + --dry-runpretty 模式下 dry-run
// 输出是 plain text 列表,用 substring 校验 format=ddl 出现在 URL query 中。
func TestAppsDBTableGet_PrettyFormatSendsFormatDDLQuery(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBTableGet,
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/tables/orders") {
t.Fatalf("missing URL in dry-run output:\n%s", got)
}
if !strings.Contains(got, "format=ddl") {
t.Fatalf("--format=pretty should trigger ?format=ddl, got:\n%s", got)
}
}
func TestAppsDBTableGet_NonPrettyFormatsOmitFormatQuery(t *testing.T) {
// 默认 json / table / ndjson / csv 都走 schema 路径 —— CLI 不传 format query。
for _, format := range []string{"json", "table", "ndjson", "csv"} {
t.Run(format, func(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
args := []string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", format, "--dry-run", "--as", "user"}
if err := runAppsShortcut(t, AppsDBTableGet, args, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v", err)
}
if _, ok := env.API[0].Params["format"]; ok {
t.Fatalf("--format=%s should omit format query, got %v", format, env.API[0].Params)
}
})
}
}
func TestAppsDBTableGet_PrettyOutputIsDDLTextOnly(t *testing.T) {
// pretty 模式 stdout 直接打 ddl 字段文本,无 envelope / 表格包装。
factory, stdout, reg := newAppsExecuteFactory(t)
ddl := "CREATE TABLE orders (\n id bigint NOT NULL,\n PRIMARY KEY (id)\n);"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/tables/orders",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ddl": ddl},
},
})
if err := runAppsShortcut(t, AppsDBTableGet,
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "CREATE TABLE orders") {
t.Fatalf("pretty output should contain raw DDL, got:\n%s", got)
}
if strings.Contains(got, `"data":`) || strings.Contains(got, `"ddl":`) {
t.Fatalf("pretty output should not be JSON envelope, got:\n%s", got)
}
}
func TestAppsDBTableGet_RequiresTable(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBTableGet,
[]string{"+db-table-get", "--app-id", "app_x", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "table") {
t.Fatalf("expected table required error, got %v", err)
}
}

View File

@@ -1,301 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const dbTableListHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
// AppsDBTableList lists tables in a Miaoda app's database.
//
// GET /apps/{app_id}/tablescursor 分页response items[] 含 estimated_row_count /
// size_bytes optional 字段,默认返回,不必额外传 query。
//
// 输出裁剪server 给每张表回完整 columns[](与 +db-table-get 同源、内容一致。CLI 用白名单
// 投影dbTableListItem只组装产品要求字段、把 columns[] 折算成 column_count避免逐表重复列定义
// 放大 token、并与 +db-table-get 职责区分。完整列定义 / 索引 / 约束 / DDL 用 +db-table-get。
//
// pretty 渲染 5 列name / description / estimated_row_count / size / columns即 column_count
// 列间两空格、列对齐填充、空 description 用 "—" 占位、size 按 KB/MB/GB 友好格式化。
var AppsDBTableList = common.Shortcut{
Service: appsService,
Command: "+db-table-list",
Description: "List tables in a Miaoda app database (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-table-list --app-id <app_id>",
"Tip: filter fields with --jq, e.g. -q '.data.items[].name'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appTablesPath(appID)).
Desc("List Miaoda app db tables").
Params(buildDBTableListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appTablesPath(appID), buildDBTableListParams(rctx), nil)
if err != nil {
return withAppsHint(err, dbTableListHint)
}
// 白名单投影:只把产品要求的字段组装进 dbTableListItem替换 server 原始 items[]。
// server 给每张表回完整 columns[](与 +db-table-get 同源、逐字节一致),在 list 里逐表
// 重复既放大 token 又与 schema 职责重叠。这里用白名单而非 delete 黑名单 —— server 后续新增
// 字段不会自动泄漏进 CLI 输出。需要完整列定义 / 索引 / 约束 / DDL 用 +db-table-get。
items := projectTableListItems(data["items"])
data["items"] = items
rctx.OutFormat(data, nil, func(w io.Writer) {
renderTableListPretty(w, items)
})
return nil
},
}
// dbTableListItem 是 +db-table-list 对外输出的「产品要求字段」白名单。
// 改字段在此处增删即可,无需在 Execute 里逐个 delete server 返回的多余字段。
type dbTableListItem struct {
Name string `json:"name"`
Description string `json:"description"`
EstimatedRowCount interface{} `json:"estimated_row_count,omitempty"`
SizeBytes interface{} `json:"size_bytes,omitempty"`
ColumnCount int `json:"column_count"`
}
// projectTableListItems 把 server 原始 items[]map投影成白名单 dbTableListItem 切片。
// column_count 由 server 返回的 columns[] 长度派生(随后 columns[] 不再透出)。
func projectTableListItems(raw interface{}) []dbTableListItem {
arr, _ := raw.([]interface{})
out := make([]dbTableListItem, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
out = append(out, dbTableListItem{
Name: common.GetString(m, "name"),
Description: common.GetString(m, "description"),
EstimatedRowCount: m["estimated_row_count"],
SizeBytes: m["size_bytes"],
ColumnCount: deriveColumnCount(m),
})
}
return out
}
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"env": rctx.Str("env"),
"page_size": rctx.Int("page-size"),
}
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
params["page_token"] = token
}
return params
}
// renderTableListPretty 5 列输出,列间两空格、列对齐填充。
//
// 列名name / description / estimated_row_count / size / columns。
// 空 description 用 "—" 占位size 由 size_bytes 经 humanBytes 友好格式化;
// columns 列取白名单投影的 column_count。
func renderTableListPretty(w io.Writer, items []dbTableListItem) {
headers := []string{"name", "description", "estimated_row_count", "size", "columns"}
rows := make([][]string, 0, len(items))
for _, item := range items {
desc := item.Description
if desc == "" {
desc = "—"
}
rows = append(rows, []string{
item.Name,
desc,
intString(item.EstimatedRowCount),
humanBytes(item.SizeBytes),
fmt.Sprintf("%d", item.ColumnCount),
})
}
renderAlignedTable(w, headers, rows)
}
// renderAlignedTable 输出列对齐表格:列间两空格、列宽按每列最长 cell 填充、
// 不画 `|` 和 `-` 分隔线、不依赖 TTY 着色。
func renderAlignedTable(w io.Writer, headers []string, rows [][]string) {
if len(headers) == 0 {
return
}
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = displayWidth(h)
}
for _, row := range rows {
for i, cell := range row {
if i >= len(widths) {
break
}
if dw := displayWidth(cell); dw > widths[i] {
widths[i] = dw
}
}
}
writeRow := func(cells []string) {
for i, cell := range cells {
if i >= len(widths) {
continue
}
if i > 0 {
io.WriteString(w, " ")
}
io.WriteString(w, cell)
if i < len(widths)-1 {
pad := widths[i] - displayWidth(cell)
if pad > 0 {
io.WriteString(w, strings.Repeat(" ", pad))
}
}
}
io.WriteString(w, "\n")
}
writeRow(headers)
for _, r := range rows {
writeRow(r)
}
}
// displayWidth 估算字符串在 monospace 终端下的显示宽度。
// ASCII 占 1 列CJK / 全角字符占 2 列;其他多字节字符按 rune 数算(保守)。
func displayWidth(s string) int {
w := 0
for _, r := range s {
switch {
case r < 0x80:
w++
case isWide(r):
w += 2
default:
w++
}
}
return w
}
func isWide(r rune) bool {
switch {
case r >= 0x1100 && r <= 0x115F: // Hangul Jamo
case r >= 0x2E80 && r <= 0x303E: // CJK Radicals / Kangxi
case r >= 0x3041 && r <= 0x33FF: // Hiragana / Katakana / Bopomofo / CJK Symbols
case r >= 0x3400 && r <= 0x4DBF: // CJK Extension A
case r >= 0x4E00 && r <= 0x9FFF: // CJK Unified Ideographs
case r >= 0xA000 && r <= 0xA4CF: // Yi
case r >= 0xAC00 && r <= 0xD7A3: // Hangul Syllables
case r >= 0xF900 && r <= 0xFAFF: // CJK Compatibility Ideographs
case r >= 0xFE30 && r <= 0xFE4F: // CJK Compatibility Forms
case r >= 0xFF00 && r <= 0xFF60: // Fullwidth Forms
case r >= 0xFFE0 && r <= 0xFFE6: // Fullwidth Signs
case r >= 0x20000 && r <= 0x2FFFD: // CJK Extension B-F
case r >= 0x30000 && r <= 0x3FFFD: // CJK Extension G
default:
return false
}
return true
}
// humanBytes 把 size_bytes 数值转 KB / MB / GB 友好字符串。
// 1 KiB = 1024 B与 PG / 操作系统约定一致。
func humanBytes(raw interface{}) string {
n, ok := numericAsFloat(raw)
if !ok {
return "—"
}
const unit = 1024.0
switch {
case n < unit:
return fmt.Sprintf("%d B", int64(n))
case n < unit*unit:
return fmt.Sprintf("%.0f KB", n/unit)
case n < unit*unit*unit:
return formatFloat(n/(unit*unit)) + " MB"
default:
return formatFloat(n/(unit*unit*unit)) + " GB"
}
}
// formatFloat 一位小数整数值省略小数24 KB 而不是 24.0 KB1.5 MB 而不是 1 MB
func formatFloat(f float64) string {
if f == float64(int64(f)) {
return fmt.Sprintf("%d", int64(f))
}
return fmt.Sprintf("%.1f", f)
}
// intString 把 JSON 反序列化进来的 number 转为整数字符串显示estimated_row_count
func intString(raw interface{}) string {
if n, ok := numericAsFloat(raw); ok {
return fmt.Sprintf("%d", int64(n))
}
return "—"
}
func numericAsFloat(raw interface{}) (float64, bool) {
switch v := raw.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int32:
return float64(v), true
case int64:
return float64(v), true
case uint:
return float64(v), true
case uint32:
return float64(v), true
case uint64:
return float64(v), true
case json.Number:
f, err := v.Float64()
if err != nil {
return 0, false
}
return f, true
case nil:
return 0, false
}
return 0, false
}
// deriveColumnCount 从 items[i].columns 数组长度派生 column_count。
func deriveColumnCount(m map[string]interface{}) int {
cols, ok := m["columns"].([]interface{})
if !ok {
return 0
}
return len(cols)
}

View File

@@ -1,309 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope 验证 server 业务错误
// code != 0如单环境 app 查 env=dev 返 "Invalid DB Branch")被 CLI 透出成
// typed error —— 用 BOE 实测的错误码 / 文案做输入。
//
// 迁移到 runtime.CallAPITyped 后,非零 code 的业务错误由 errclass.BuildAPIError
// 归类为 typed errs.* errorwire type 为 "api" 类别,不再是 legacy 的
// *output.ExitError / "api_error"),但仍保留 code 与 message。与 drive/okr 等
// 已迁移域一致:用 errs.ProblemOf 读 typed envelope断言不弱化。
func TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/tables",
Body: map[string]interface{}{
"code": 500002511,
"msg": "k_dl_1600000Invalid DB Branchdev",
},
})
err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.Problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryAPI {
t.Fatalf("error.type = %q, want %q", p.Category, errs.CategoryAPI)
}
if p.Code != 500002511 {
t.Fatalf("error.code = %d, want 500002511", p.Code)
}
if !strings.Contains(p.Message, "Invalid DB Branch") {
t.Fatalf("error.message missing 'Invalid DB Branch': %q", p.Message)
}
}
func TestAppsDBTableList_SuccessReturnsItemsWithStats(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/tables",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{
"name": "orders",
"description": "订单表",
"columns": []interface{}{map[string]interface{}{"name": "id"}, map[string]interface{}{"name": "user_id"}},
"estimated_row_count": 1200,
"size_bytes": 81920,
},
},
},
},
})
if err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"name": "orders"`) {
t.Fatalf("stdout missing table name: %s", got)
}
if !strings.Contains(got, `"estimated_row_count": 1200`) {
t.Fatalf("stdout missing estimated_row_count: %s", got)
}
// CLI 裁剪json 默认不透出每表 columns[],折算成 column_countmock 给了 2 列)。
if !strings.Contains(got, `"column_count": 2`) {
t.Fatalf("stdout missing column_count (should replace columns[]): %s", got)
}
if strings.Contains(got, `"columns"`) {
t.Fatalf("stdout should NOT contain raw columns[] (stripped to column_count): %s", got)
}
}
// pretty 5 列 + 列名 (size / columns不是 size_bytes / column_count) + size 友好格式KB +
// 空 description 用 "—" 占位。
func TestAppsDBTableList_PrettyRendersFiveColumnsHumanReadable(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/tables",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"name": "orders",
"description": "Order entries",
"columns": []interface{}{map[string]interface{}{"name": "id"}, map[string]interface{}{"name": "user_id"}},
"estimated_row_count": 1200,
"size_bytes": 81920, // 80 KB
},
map[string]interface{}{
"name": "customers",
"description": "",
"columns": []interface{}{map[string]interface{}{"name": "id"}},
"estimated_row_count": 350,
"size_bytes": 24576, // 24 KB
},
},
},
},
})
if err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// Header 行 5 列命名。
wantHeader := "name description estimated_row_count size columns"
// rows
wantOrders := "orders Order entries 1200 80 KB 2"
wantCustomers := "customers — 350 24 KB 1"
for _, want := range []string{wantHeader, wantOrders, wantCustomers} {
if !strings.Contains(got, want) {
t.Errorf("missing line %q\nactual output:\n%s", want, got)
}
}
// 禁止出现旧列名 / 原始字节。
for _, banned := range []string{"size_bytes", "column_count", "81920", "24576"} {
if strings.Contains(got, banned) {
t.Errorf("pretty output contains %q (must be human-formatted)\noutput:\n%s", banned, got)
}
}
}
func TestAppsDBTableList_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", " ", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected app-id required error, got %v", err)
}
}
func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev",
"--page-size", "50", "--page-token", "cursor-abc",
"--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
if env.API[0].Method != "GET" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/tables" {
t.Fatalf("dry-run method/url = %s %s", env.API[0].Method, env.API[0].URL)
}
if env.API[0].Params["env"] != "dev" {
t.Fatalf("dry-run params.env = %v (want dev)", env.API[0].Params["env"])
}
if pz, _ := env.API[0].Params["page_size"].(float64); int(pz) != 50 {
t.Fatalf("dry-run params.page_size = %v (want 50)", env.API[0].Params["page_size"])
}
if env.API[0].Params["page_token"] != "cursor-abc" {
t.Fatalf("dry-run params.page_token = %v (want cursor-abc)", env.API[0].Params["page_token"])
}
}
func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v", err)
}
if _, ok := env.API[0].Params["include_stats"]; ok {
t.Fatalf("CLI should not send include_stats query, but got params=%v", env.API[0].Params)
}
}
func TestAppsDBTableList_RejectsBadEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--env", "prod", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "env") {
t.Fatalf("expected env enum rejection, got %v", err)
}
}
func TestNumericAsFloat_AllTypes(t *testing.T) {
cases := []struct {
name string
in interface{}
want float64
ok bool
}{
{"float64", float64(3.5), 3.5, true},
{"float32", float32(2), 2, true},
{"int", int(7), 7, true},
{"int32", int32(8), 8, true},
{"int64", int64(9), 9, true},
{"uint", uint(10), 10, true},
{"uint32", uint32(11), 11, true},
{"uint64", uint64(12), 12, true},
{"json.Number valid", json.Number("13.5"), 13.5, true},
{"json.Number invalid", json.Number("abc"), 0, false},
{"nil", nil, 0, false},
{"unsupported string", "x", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := numericAsFloat(c.in)
if ok != c.ok || got != c.want {
t.Fatalf("numericAsFloat(%v) = %v,%v want %v,%v", c.in, got, ok, c.want, c.ok)
}
})
}
}
func TestFormatFloat_IntegerVsFractional(t *testing.T) {
cases := []struct {
in float64
want string
}{
{24, "24"},
{1.5, "1.5"},
{2.04, "2.0"},
{0, "0"},
}
for _, c := range cases {
if got := formatFloat(c.in); got != c.want {
t.Errorf("formatFloat(%v)=%q want %q", c.in, got, c.want)
}
}
}
func TestHumanBytes_UnitBoundaries(t *testing.T) {
cases := []struct {
name string
in interface{}
want string
}{
{"non-numeric", "x", "—"},
{"bytes", float64(512), "512 B"},
{"kb", float64(2048), "2 KB"},
{"mb fractional", float64(1572864), "1.5 MB"},
{"gb integer", float64(2 * 1024 * 1024 * 1024), "2 GB"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := humanBytes(c.in); got != c.want {
t.Errorf("humanBytes(%v)=%q want %q", c.in, got, c.want)
}
})
}
}
func TestIntString_Cases(t *testing.T) {
if got := intString(float64(42)); got != "42" {
t.Errorf("intString(42)=%q want 42", got)
}
if got := intString("x"); got != "—" {
t.Errorf("intString(non-numeric)=%q want —", got)
}
}
func TestDeriveColumnCount_Cases(t *testing.T) {
if got := deriveColumnCount(map[string]interface{}{"columns": []interface{}{1, 2, 3}}); got != 3 {
t.Errorf("deriveColumnCount=%d want 3", got)
}
if got := deriveColumnCount(map[string]interface{}{}); got != 0 {
t.Errorf("deriveColumnCount(missing)=%d want 0", got)
}
if got := deriveColumnCount(map[string]interface{}{"columns": "notarray"}); got != 0 {
t.Errorf("deriveColumnCount(wrongtype)=%d want 0", got)
}
}

View File

@@ -1,380 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// envKeyPattern matches valid environment variable names: [A-Za-z_][A-Za-z0-9_]*
var envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type envPullDatabaseInfo struct {
Detected bool
ExpiresAtRaw string
ExpiresAtText string
}
// AppsEnvPull pulls startup env vars for an app into the local .env.local file.
var AppsEnvPull = common.Shortcut{
Service: appsService,
Command: "+env-pull",
Description: "Pull app startup env vars into the local project .env.local",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +env-pull --app-id <app_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID"},
{Name: "project-path", Desc: "local project root path (defaults to current directory)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: "--app-id is required"}, Param: "app-id"}
}
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
if err != nil {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
}
if err := checkEnvPullTarget(envFile); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Pull app startup env vars into the local .env.local file").
Set("project_path", projectPath).
Set("env_file", envFile)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
if err != nil {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
}
if err := checkEnvPullTarget(envFile); err != nil {
return err
}
if err := rctx.EnsureScopes([]string{"spark:app:read"}); err != nil {
return err
}
path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("POST", path, nil, nil)
if err != nil {
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
}
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
if err != nil {
return err
}
if envVars == nil {
envVars = map[string]string{}
}
envVars["FORCE_DB_BRANCH"] = "dev"
original, err := readEnvPullFile(envFile)
if err != nil {
return err
}
merged, updated, created := mergeEnvPullFileContent(original, envVars)
if err := ensureEnvPullParentDir(envFile); err != nil {
return err
}
if err := validate.AtomicWrite(envFile, []byte(merged), 0o600); err != nil {
return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot write %s: %v", envFile, err)}, Cause: err}
}
result := buildEnvPullSuccessData(appID, envFile, databaseInfo)
rctx.OutFormat(result, nil, func(w io.Writer) {
writeEnvPullPretty(w, appID, envFile, databaseInfo, skippedKeys)
})
_ = updated
_ = created
return nil
},
}
func resolveEnvPullTarget(projectPath string) (string, string, error) {
if strings.TrimSpace(projectPath) == "" {
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
if err != nil {
return "", "", fmt.Errorf("cannot determine working directory: %w", err)
}
projectPath = cwd
}
if err := validate.RejectControlChars(projectPath, "--project-path"); err != nil {
return "", "", err
}
projectPath = filepath.Clean(projectPath)
return projectPath, filepath.Join(projectPath, ".env.local"), nil
}
func checkEnvPullTarget(envFile string) error {
info, err := os.Lstat(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; direct lstat is needed to reject symlinks before write.
if err != nil {
if os.IsNotExist(err) {
return nil
}
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("cannot inspect %s: %v", envFile, err)}, Param: "project-path", Cause: err}
}
if info.Mode()&os.ModeSymlink != 0 {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file, not a symlink", envFile)}, Param: "project-path"}
}
if !info.Mode().IsRegular() {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file", envFile)}, Param: "project-path"}
}
return nil
}
func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPullDatabaseInfo, []string, error) {
raw := data["env_vars"]
if raw == nil {
if nested, ok := data["data"].(map[string]interface{}); ok {
raw = nested["env_vars"]
}
}
if raw == nil {
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
}
var skippedKeys []string
switch typed := raw.(type) {
case map[string]interface{}:
out := make(map[string]string, len(typed))
for key, value := range typed {
if !envKeyPattern.MatchString(key) {
skippedKeys = append(skippedKeys, key)
continue
}
s, ok := value.(string)
if !ok {
continue
}
out[key] = s
}
return out, envPullDatabaseInfo{Detected: hasEnvPullDatabase(out)}, skippedKeys, nil
case []interface{}:
out := make(map[string]string, len(typed))
info := envPullDatabaseInfo{}
for _, item := range typed {
entry, ok := item.(map[string]interface{})
if !ok {
continue
}
key, ok := entry["key"].(string)
if !ok || strings.TrimSpace(key) == "" {
continue
}
if !envKeyPattern.MatchString(key) {
skippedKeys = append(skippedKeys, key)
continue
}
value, ok := entry["value"].(string)
if !ok {
continue
}
out[key] = value
if key == "SUDA_DATABASE_URL" {
info.Detected = true
info.ExpiresAtRaw, info.ExpiresAtText = extractEnvPullDatabaseExpiry(entry["extras"])
}
}
return out, info, skippedKeys, nil
default:
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
}
}
func readEnvPullFile(envFile string) (string, error) {
data, err := os.ReadFile(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; validated local file read for a single env file.
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot read %s: %v", envFile, err)}, Cause: err}
}
return string(data), nil
}
func ensureEnvPullParentDir(envFile string) error {
dir := filepath.Dir(envFile)
if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local mkdir for target env parent dir.
return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot create %s: %v", dir, err)}, Cause: err}
}
return nil
}
func mergeEnvPullFileContent(original string, envVars map[string]string) (string, []string, []string) {
if len(envVars) == 0 {
if original == "" {
return "", nil, nil
}
return ensureTrailingNewline(original), nil, nil
}
normalized := strings.ReplaceAll(original, "\r\n", "\n")
lines := []string{}
if normalized != "" {
lines = strings.Split(normalized, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
}
used := make(map[string]bool, len(envVars))
updated := make([]string, 0, len(envVars))
for i, line := range lines {
key, ok := parseEnvPullAssignmentLine(line)
if !ok {
continue
}
value, exists := envVars[key]
if !exists {
continue
}
lines[i] = formatEnvPullAssignment(key, value)
updated = append(updated, key)
used[key] = true
}
created := make([]string, 0, len(envVars))
pending := make([]string, 0, len(envVars))
for key := range envVars {
if used[key] {
continue
}
pending = append(pending, key)
}
sort.Strings(pending)
for _, key := range pending {
lines = append(lines, formatEnvPullAssignment(key, envVars[key]))
created = append(created, key)
}
sort.Strings(updated)
content := strings.Join(lines, "\n")
if content != "" {
content += "\n"
}
return content, updated, created
}
func parseEnvPullAssignmentLine(line string) (string, bool) {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
return "", false
}
if strings.HasPrefix(trimmed, "export ") || strings.HasPrefix(trimmed, "export\t") {
remainder := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(trimmed, "export "), "export\t"))
if remainder == "" || strings.HasPrefix(remainder, "=") {
return "", false
}
trimmed = remainder
}
idx := strings.Index(trimmed, "=")
if idx <= 0 {
return "", false
}
key := strings.TrimSpace(trimmed[:idx])
if key == "" || strings.ContainsAny(key, " \t") {
return "", false
}
return key, true
}
func formatEnvPullAssignment(key, value string) string {
return fmt.Sprintf("%s=%s", key, strconv.Quote(value))
}
func buildEnvPullSuccessData(appID, envFile string, databaseInfo envPullDatabaseInfo) map[string]interface{} {
result := map[string]interface{}{
"app_id": appID,
"env_file": envFile,
}
if databaseInfo.ExpiresAtRaw != "" {
result["database_url_expires_at"] = databaseInfo.ExpiresAtRaw
}
return result
}
func hasEnvPullDatabase(envVars map[string]string) bool {
_, ok := envVars["SUDA_DATABASE_URL"]
return ok
}
func extractEnvPullDatabaseExpiry(rawExtras interface{}) (string, string) {
extras, ok := rawExtras.([]interface{})
if !ok {
return "", ""
}
for _, raw := range extras {
entry, ok := raw.(map[string]interface{})
if !ok {
continue
}
key, _ := entry["key"].(string)
if key != "expiresAt" {
continue
}
switch value := entry["value"].(type) {
case string:
rawValue := strings.TrimSpace(value)
ts, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
return "", ""
}
return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
case float64:
ts := int64(value)
rawValue := strconv.FormatInt(ts, 10)
return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
}
}
return "", ""
}
func writeEnvPullPretty(w io.Writer, appID, envFile string, databaseInfo envPullDatabaseInfo, skippedKeys []string) {
fmt.Fprintf(w, "✓ App detected: %s\n", appID)
if databaseInfo.Detected {
fmt.Fprintln(w, "✓ Development database detected")
}
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
if databaseInfo.ExpiresAtText != "" {
fmt.Fprintln(w)
fmt.Fprintf(w, "DATABASE_URL is valid until %s.\n", databaseInfo.ExpiresAtText)
}
if len(skippedKeys) > 0 {
fmt.Fprintln(w)
fmt.Fprintf(w, "⚠ Skipped %d invalid key(s): %s (key names must match [A-Za-z_][A-Za-z0-9_]*)\n", len(skippedKeys), strings.Join(skippedKeys, ", "))
}
fmt.Fprintf(w, "Run `lark-cli apps +env-pull --app-id <app_id>` again to refresh it.\n")
}
func ensureTrailingNewline(s string) string {
if s == "" || strings.HasSuffix(s, "\n") {
return s
}
return s + "\n"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"regexp"
"strings"
"testing"
)
func TestAppsShortcutsHaveExamples(t *testing.T) {
realAppID := regexp.MustCompile(`app_[a-z0-9]{6,}`)
email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`)
for _, s := range Shortcuts() {
hasExample := false
for _, tip := range s.Tips {
if strings.HasPrefix(tip, "Example: lark-cli apps +") {
hasExample = true
}
if realAppID.MatchString(tip) {
t.Errorf("%s tip leaks real-looking app id (use <app_id>): %q", s.Command, tip)
}
if email.MatchString(tip) || phone.MatchString(tip) {
t.Errorf("%s tip leaks PII: %q", s.Command, tip)
}
}
if !hasExample {
t.Errorf("%s has no \"Example: lark-cli apps +...\" tip", s.Command)
}
}
}
func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) {
want := map[string]int{"+chat": 2, "+access-scope-set": 2}
for _, s := range Shortcuts() {
min, ok := want[s.Command]
if !ok {
continue
}
n := 0
for _, tip := range s.Tips {
if strings.HasPrefix(tip, "Example: lark-cli apps +") {
n++
}
}
if n < min {
t.Errorf("%s has %d Example tips, want >= %d", s.Command, n, min)
}
}
}

View File

@@ -1,148 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsFileDelete batch-deletes files by remote pathhigh-risk-write框架自动注入 --yes 确认)。
//
// POST /apps/{app_id}/storage/file_batch_removebody {paths:[...]}。网关把该路由注册为 POST
// DELETE-with-body 不被网关支持,实测 DELETE→404 / POST→200。后端 results[] 与请求 paths
// 顺序一一对应:成功项带 file失败项带 error_codeCLI 据下标回填 path
// 部分失败整体仍 ok:true —— 失败项落在 data.results[].error不翻成非 0 退出码lark-cli 信封语义)。
var AppsFileDelete = common.Shortcut{
Service: appsService,
Command: "+file-delete",
Description: "Delete one or more files by remote path (batch)",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +file-delete --app-id <app_id> --path /1858537546760216.png --yes",
"Repeat --path for batch delete.",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "path", Type: "string_slice", Desc: "remote file path to delete (repeatable)", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if len(cleanDeletePaths(rctx)) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--path is required (at least one remote path)").WithParam("--path")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appFileBatchRemovePath(appID)).
Desc("Batch delete Miaoda app files").
Body(map[string]interface{}{"paths": cleanDeletePaths(rctx)})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
paths := cleanDeletePaths(rctx)
data, err := rctx.CallAPITyped("POST", appFileBatchRemovePath(appID), nil, map[string]interface{}{"paths": paths})
if err != nil {
return err
}
results := projectDeleteResults(data["results"], paths)
out := map[string]interface{}{"results": results}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderFileDeletePretty(w, results)
})
return nil
},
}
// cleanDeletePaths 取 --path 切片trim 去空。
func cleanDeletePaths(rctx *common.RuntimeContext) []string {
out := make([]string, 0)
for _, p := range rctx.StrSlice("path") {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
// projectDeleteResults 把后端 results[] 按下标 zip 回请求 paths回填 path
// 失败项把 error_code 包成 {code,message} 便于消费。
func projectDeleteResults(raw interface{}, inputs []string) []map[string]interface{} {
arr, _ := raw.([]interface{})
out := make([]map[string]interface{}, 0, len(inputs))
for i, input := range inputs {
var r map[string]interface{}
if i < len(arr) {
r, _ = arr[i].(map[string]interface{})
}
status := "ok"
if r != nil && common.GetString(r, "status") != "" {
status = common.GetString(r, "status")
}
item := map[string]interface{}{"status": status, "path": input}
if status == "ok" {
if r != nil {
if f, ok := r["file"].(map[string]interface{}); ok {
item["file_name"] = common.GetString(f, "file_name")
}
}
} else {
code := ""
if r != nil {
code = common.GetString(r, "error_code")
}
if code == "" {
code = "DELETE_FAILED"
}
item["error"] = map[string]interface{}{
"code": code,
"message": deleteErrorMessage(code, input),
}
}
out = append(out, item)
}
return out
}
// deleteErrorMessage 据 error_code 生成删除失败文案FILE_NOT_FOUND 提示文件不存在,其余统一删除失败。
func deleteErrorMessage(code, path string) string {
if code == "FILE_NOT_FOUND" {
return fmt.Sprintf("File '%s' does not exist", path)
}
return fmt.Sprintf("Failed to delete '%s'", path)
}
// renderFileDeletePretty 逐项打 ✓ / ✗,末行汇总 deleted 计数。
func renderFileDeletePretty(w io.Writer, results []map[string]interface{}) {
okCount := 0
for _, r := range results {
path := common.GetString(r, "path")
if common.GetString(r, "status") == "ok" {
fmt.Fprintf(w, "✓ %s\n", path)
okCount++
continue
}
code := ""
if e, ok := r["error"].(map[string]interface{}); ok {
code = common.GetString(e, "code")
}
fmt.Fprintf(w, "✗ %s (%s)\n", path, code)
}
fmt.Fprintf(w, "\n%d/%d deleted\n", okCount, len(results))
}

View File

@@ -1,132 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const fileDeleteURL = "/open-apis/spark/v1/apps/app_x/storage/file_batch_remove"
// TestAppsFileDelete_RequiresAppIDAndPath 验证仅含空白的 --path 去空后为空时Validate 报 --path typed 校验错误。
func TestAppsFileDelete_RequiresAppIDAndPath(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
// 传入仅含空白的 --path满足 cobra 的 Required 检查,但 cleanDeletePaths 去空后为空,
// 触发 Validate 内的 typed --path 校验。
err := runAppsShortcut(t, AppsFileDelete,
[]string{"+file-delete", "--app-id", "app_x", "--path", " ", "--yes", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--path" {
t.Fatalf("Param = %q, want --path", ve.Param)
}
}
// high-risk-write无 --yes → confirmation_requiredexit 10
func TestAppsFileDelete_RequiresConfirmation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileDelete,
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected confirmation_required, got %v", err)
}
}
// TestAppsFileDelete_DryRunSendsPaths 验证 dry-run 输出 POST file_batch_removebody.paths 按序携带多个 --path。
func TestAppsFileDelete_DryRunSendsPaths(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileDelete,
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/b.png", "--yes", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != fileDeleteURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
paths, _ := a.Body["paths"].([]interface{})
if len(paths) != 2 || paths[0] != "/a.png" || paths[1] != "/b.png" {
t.Fatalf("body.paths = %v", a.Body["paths"])
}
}
// 部分失败仍 ok:trueresults 按下标 zip 回 path失败项带 error{code,message}。
func TestAppsFileDelete_PartialFailureStillOK(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: fileDeleteURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"results": []interface{}{
map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png", "path": "/a.png"}},
map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"},
},
}},
})
err := runAppsShortcut(t, AppsFileDelete,
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("partial failure should NOT error (ok:true semantics), got %v", err)
}
got := stdout.String()
var env struct {
Data struct {
Results []map[string]interface{} `json:"results"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(got), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, got)
}
if len(env.Data.Results) != 2 {
t.Fatalf("want 2 results, got %d: %s", len(env.Data.Results), got)
}
r0, r1 := env.Data.Results[0], env.Data.Results[1]
if r0["status"] != "ok" || r0["path"] != "/a.png" {
t.Errorf("result[0] = %v", r0)
}
if r1["status"] != "error" || r1["path"] != "/missing.png" {
t.Errorf("result[1] = %v (path must be back-filled by index)", r1)
}
if e, ok := r1["error"].(map[string]interface{}); !ok || e["code"] != "FILE_NOT_FOUND" {
t.Errorf("result[1].error = %v (want code FILE_NOT_FOUND)", r1["error"])
}
}
// TestAppsFileDelete_PrettySummary 验证 pretty 输出逐项 ✓/✗ 标记并汇总 "1/2 deleted"。
func TestAppsFileDelete_PrettySummary(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: fileDeleteURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"results": []interface{}{
map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png"}},
map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"},
},
}},
})
if err := runAppsShortcut(t, AppsFileDelete,
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{"✓ /a.png", "✗ /missing.png (FILE_NOT_FOUND)", "1/2 deleted"} {
if !strings.Contains(got, want) {
t.Errorf("pretty missing %q:\n%s", want, got)
}
}
}

View File

@@ -1,122 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"net/http"
"path"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsFileDownload downloads a file to a local path via a signed URL。
//
// 两步POST /apps/{app_id}/storage/file_sign 拿 signed_urlpresigned直连对象存储
// 再客户端 GET signed_url 落盘到 --output默认远端 basename。不单设 download 接口。
var AppsFileDownload = common.Shortcut{
Service: appsService,
Command: "+file-download",
Description: "Download a file to a local path (via a signed URL)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +file-download --app-id <app_id> --path /1858537546760216.png --output ./logo.png",
"Example (omit --output): lark-cli apps +file-download --app-id <app_id> --path /1858537546760216.png # saves to ./1858537546760216.png",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "path", Desc: "remote file path", Required: true},
{Name: "output", Desc: "local output path (default: remote file basename in cwd)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
_, err := requireFilePath(rctx.Str("path"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
remotePath, _ := requireFilePath(rctx.Str("path"))
return common.NewDryRunAPI().
POST(appFileSignPath(appID)).
Desc("Sign a download URL, then GET it to --output").
Body(map[string]interface{}{"path": remotePath})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
remotePath, err := requireFilePath(rctx.Str("path"))
if err != nil {
return err
}
// 1. 签名拿 presigned signed_url。
signData, err := rctx.CallAPITyped("POST", appFileSignPath(appID), nil, map[string]interface{}{"path": remotePath})
if err != nil {
return err
}
signedURL := common.GetString(signData, "signed_url")
if signedURL == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "sign returned no signed_url")
}
// 2. 直连 GET signed_url 落盘。
out := strings.TrimSpace(rctx.Str("output"))
if out == "" {
out = path.Base(strings.TrimPrefix(remotePath, "/"))
if out == "" || out == "." || out == "/" {
out = "download"
}
}
req, err := http.NewRequestWithContext(rctx.Ctx(), http.MethodGet, signedURL, nil) //nolint:forbidigo // GET from a presigned object-storage URL bypasses the Lark gateway; raw HTTP required (not a Lark API call).
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "build download request").WithCause(err)
}
resp, err := newFileTransferClient().Do(req) //nolint:forbidigo // see above: direct presigned-URL download, RuntimeContext.DoAPI does not apply.
if err != nil {
// dial/transport 失败是典型可重试场景。
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed").WithCause(err).WithRetryable()
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
// 5xx 是上游瞬时故障,标 retryable4xx如签名过期需重新签名而非盲重试不标。
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed: HTTP %d", resp.StatusCode).WithRetryable()
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
}
saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output").WithCause(err)
}
resolved, perr := rctx.FileIO().ResolvePath(out)
if perr != nil || resolved == "" {
resolved = out
}
result := map[string]interface{}{
"path": remotePath,
"output": resolved,
"size_bytes": saved.Size(),
}
rctx.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Downloaded %s → %s (%s)\n", remotePath, resolved, humanBytes(saved.Size()))
})
return nil
},
}

View File

@@ -1,122 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const fileSignURLForDownload = "/open-apis/spark/v1/apps/app_x/storage/file_sign"
// TestAppsFileDownload_RequiresAppIDAndPath 验证仅含空白的 --path 触发 --path typed 校验错误。
func TestAppsFileDownload_RequiresAppIDAndPath(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileDownload,
[]string{"+file-download", "--app-id", "app_x", "--path", " ", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--path" {
t.Fatalf("Param = %q, want --path", ve.Param)
}
}
// TestAppsFileDownload_DryRunSignsFirst 验证 dry-run 第一步是 POST file_sign。
func TestAppsFileDownload_DryRunSignsFirst(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileDownload,
[]string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Method != "POST" || env.API[0].URL != fileSignURLForDownload {
t.Fatalf("dry-run = %s %s (want POST sign)", env.API[0].Method, env.API[0].URL)
}
}
// sign → 客户端 GET presigned signed_url → 落盘 --output。
func TestAppsFileDownload_EndToEnd(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "image/png")
io.WriteString(w, "PNGDATA")
}))
defer srv.Close()
dir := t.TempDir()
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: fileSignURLForDownload,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"signed_url": srv.URL}},
})
if err := runAppsShortcut(t, AppsFileDownload,
[]string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--output", "out.png", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
b, err := os.ReadFile(filepath.Join(dir, "out.png"))
if err != nil {
t.Fatalf("read output file: %v", err)
}
if string(b) != "PNGDATA" {
t.Fatalf("downloaded content = %q, want PNGDATA", b)
}
if !strings.Contains(stdout.String(), `"size_bytes": 7`) {
t.Errorf("output json missing size_bytes:7\n%s", stdout.String())
}
}
// 不传 --output → 默认远端 basename。
func TestAppsFileDownload_DefaultsOutputToBasename(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "DATA")
}))
defer srv.Close()
dir := t.TempDir()
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: fileSignURLForDownload,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"signed_url": srv.URL}},
})
if err := runAppsShortcut(t, AppsFileDownload,
[]string{"+file-download", "--app-id", "app_x", "--path", "/1858537546760216.png", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if _, err := os.Stat(filepath.Join(dir, "1858537546760216.png")); err != nil {
t.Fatalf("default output basename not written: %v", err)
}
}

View File

@@ -1,87 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsFileGet gets one file's metadata by exact remote path动词对齐 +file-list
//
// GET /apps/{app_id}/storage/file?path=<path>。file 仅按 path 精确寻址,无按名寻址。
// pretty 渲染 key/valuefile_name / path / size(含 bytes) / type / uploaded_by(只 name) / uploaded_at /
// download_url条件出现。server created_at/created_by → uploaded_at/uploaded_by。
var AppsFileGet = common.Shortcut{
Service: appsService,
Command: "+file-get",
Description: "Get a single file's metadata by path",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +file-get --app-id <app_id> --path /1858537546760216.png",
"Tip: extract a single field with --jq, e.g. -q '.size_bytes' or -q '.download_url'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "path", Desc: "remote file path", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
_, err := requireFilePath(rctx.Str("path"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appFileGetPath(appID)).
Desc("Get Miaoda app file metadata").
Params(buildFileGetParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appFileGetPath(appID), buildFileGetParams(rctx), nil)
if err != nil {
return err
}
info := projectFileInfo(data)
rctx.OutFormat(info, nil, func(w io.Writer) {
renderFileGetPretty(w, info)
})
return nil
},
}
// buildFileGetParams 组装 file_get 查询参数:按 path 精确寻址单文件。
func buildFileGetParams(rctx *common.RuntimeContext) map[string]interface{} {
path, _ := requireFilePath(rctx.Str("path"))
return map[string]interface{}{"path": path}
}
// renderFileGetPretty 输出对齐 key/valueuploaded_by 只展示 nameid 仅 json 保留)。
func renderFileGetPretty(w io.Writer, info fileInfo) {
pairs := [][2]string{
{"file_name", dashIfEmpty(info.FileName)},
{"path", info.Path},
{"size", fileSizeDetail(info.SizeBytes)},
{"type", dashIfEmpty(info.Type)},
}
if info.UploadedBy != nil {
pairs = append(pairs, [2]string{"uploaded_by", info.UploadedBy.Name})
}
pairs = append(pairs, [2]string{"uploaded_at", dashIfEmpty(info.UploadedAt)})
if info.DownloadURL != "" {
pairs = append(pairs, [2]string{"download_url", info.DownloadURL})
}
renderKeyValuePairs(w, pairs)
}

View File

@@ -1,89 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const fileGetURL = "/open-apis/spark/v1/apps/app_x/storage/file"
// TestAppsFileGet_RequiresAppIDAndPath 验证空白 --app-id 与空白 --path 分别触发对应的 typed 校验错误。
func TestAppsFileGet_RequiresAppIDAndPath(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileGet,
[]string{"+file-get", "--app-id", " ", "--path", "/x.png", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--app-id" {
t.Fatalf("Param = %q, want --app-id", ve.Param)
}
factory2, stdout2, _ := newAppsExecuteFactory(t)
err2 := runAppsShortcut(t, AppsFileGet,
[]string{"+file-get", "--app-id", "app_x", "--path", " ", "--as", "user"}, factory2, stdout2)
var ve2 *errs.ValidationError
if !errors.As(err2, &ve2) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err2, err2)
}
if ve2.Param != "--path" {
t.Fatalf("Param = %q, want --path", ve2.Param)
}
}
// TestAppsFileGet_DryRunSendsPathQuery 验证 dry-run 输出 GET filepath 作为 query 参数下发。
func TestAppsFileGet_DryRunSendsPathQuery(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileGet,
[]string{"+file-get", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Method != "GET" || env.API[0].URL != fileGetURL || env.API[0].Params["path"] != "/x.png" {
t.Fatalf("dry-run = %s %s params=%v", env.API[0].Method, env.API[0].URL, env.API[0].Params)
}
}
// TestAppsFileGet_SuccessAndPrettyKeyValue 验证 pretty key/value 展示 size 含 bytes、uploaded_by 只显示 name 且不泄漏 user id。
func TestAppsFileGet_SuccessAndPrettyKeyValue(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: fileGetURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "logo.png", "path": "/1858537546760216.png",
"size_bytes": 24580, "type": "image/png",
"created_at": "2026-04-15T10:30:00Z",
"created_by": `{"id":"7311","name":"alice"}`,
}},
})
if err := runAppsShortcut(t, AppsFileGet,
[]string{"+file-get", "--app-id", "app_x", "--path", "/1858537546760216.png", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// pretty key/valuesize 含 bytes、uploaded_by 只展示 name。
for _, want := range []string{"file_name:", "24 KB (24580 bytes)", "uploaded_by: alice", "uploaded_at: 2026-04-15T10:30:00Z"} {
if !strings.Contains(got, want) {
t.Errorf("pretty missing %q:\n%s", want, got)
}
}
// pretty 不该泄漏 user id。
if strings.Contains(got, "7311") {
t.Errorf("pretty should show name only, not id:\n%s", got)
}
}

View File

@@ -1,145 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
//
// GET /apps/{app_id}/storage/file_list。过滤器--name / --path / --type / --size-gt /
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size/--page-token。
// file 域不分 dev/online无 --env。
//
// pretty 渲染 5 列file_name / path / size / type / uploaded_at空结果打 "No files found."。
// server 字段 created_at → 产品语义 uploaded_at。
var AppsFileList = common.Shortcut{
Service: appsService,
Command: "+file-list",
Description: "List files in a Miaoda app's storage (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +file-list --app-id <app_id>",
"Tip: filter fields with --jq, e.g. -q '.data.items[].path'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "name", Desc: "filter by exact file name"},
{Name: "path", Desc: "filter by exact remote path"},
{Name: "type", Desc: "filter by MIME type"},
{Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"},
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"},
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC回写到 flag 供 buildFileListParams 透传。
for _, f := range []string{"uploaded-since", "uploaded-until"} {
if strings.TrimSpace(rctx.Str(f)) == "" {
continue
}
n, err := normalizeTimestamp(rctx.Str(f))
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: %v", f, err).WithParam("--" + f)
}
_ = rctx.Cmd.Flags().Set(f, n)
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appFileListPath(appID)).
Desc("List Miaoda app files").
Params(buildFileListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appFileListPath(appID), buildFileListParams(rctx), nil)
if err != nil {
return err
}
// 白名单投影server created_at/created_by → uploaded_at/uploaded_by替换原始 items[]。
items := projectFileItems(data["items"])
data["items"] = items
rctx.OutFormat(data, nil, func(w io.Writer) {
renderFileListPretty(w, items)
})
return nil
},
}
// projectFileItems 把服务端原始 items 逐项投影为白名单 fileInfocreated_*→uploaded_*)。
func projectFileItems(raw interface{}) []fileInfo {
arr, _ := raw.([]interface{})
out := make([]fileInfo, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]interface{}); ok {
out = append(out, projectFileInfo(m))
}
}
return out
}
// buildFileListParams 组装 file_list 查询参数page_size 及可选 name/path/type/size_gt/size_lt/uploaded_since/uploaded_until/page_token。
func buildFileListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"page_size": rctx.Int("page-size"),
}
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v
}
}
addStr("name", "name")
addStr("path", "path")
addStr("type", "type")
addStr("uploaded-since", "uploaded_since")
addStr("uploaded-until", "uploaded_until")
addStr("page-token", "page_token")
if v := rctx.Int("size-gt"); v > 0 {
params["size_gt"] = v
}
if v := rctx.Int("size-lt"); v > 0 {
params["size_lt"] = v
}
return params
}
// renderFileListPretty 5 列对齐表file_name / path / size / type / uploaded_at。
func renderFileListPretty(w io.Writer, items []fileInfo) {
if len(items) == 0 {
io.WriteString(w, "No files found.\n")
return
}
headers := []string{"file_name", "path", "size", "type", "uploaded_at"}
rows := make([][]string, 0, len(items))
for _, it := range items {
rows = append(rows, []string{
dashIfEmpty(it.FileName),
it.Path,
humanBytes(it.SizeBytes),
dashIfEmpty(it.Type),
dashIfEmpty(it.UploadedAt),
})
}
renderAlignedTable(w, headers, rows)
}

View File

@@ -1,252 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// 设计原则三:<timestamp> 四种格式 → 统一 RFC3339 UTC。
func TestNormalizeTimestamp_AllFormats(t *testing.T) {
// 空串透传
if got, err := normalizeTimestamp(" "); err != nil || got != "" {
t.Fatalf("empty → %q,%v want \"\",nil", got, err)
}
// ISO 8601 带 TZZ 原样、显式偏移换算到 UTC
mustEq := func(in, want string) {
got, err := normalizeTimestamp(in)
if err != nil || got != want {
t.Errorf("normalizeTimestamp(%q)=%q,%v want %q", in, got, err, want)
}
}
mustEq("2026-04-15T10:00:00Z", "2026-04-15T10:00:00Z")
mustEq("2026-04-15T10:00:00+08:00", "2026-04-15T02:00:00Z") // +08:00 → UTC -8h
// date / local datetime按本地时区解释再转 UTC与 time.ParseInLocation 对齐)
dExp, _ := time.ParseInLocation("2006-01-02", "2026-04-15", time.Local)
mustEq("2026-04-15", dExp.UTC().Format(time.RFC3339))
ldExp, _ := time.ParseInLocation("2006-01-02T15:04:05", "2026-04-15T10:00:00", time.Local)
mustEq("2026-04-15T10:00:00", ldExp.UTC().Format(time.RFC3339))
// 相对:从现在往前推,结果应 ≈ now-dur5s 容差)
for _, c := range []struct {
in string
dur time.Duration
}{{"30s", 30 * time.Second}, {"5m", 5 * time.Minute}, {"2h", 2 * time.Hour}, {"3d", 72 * time.Hour}, {"1w", 7 * 24 * time.Hour}} {
got, err := normalizeTimestamp(c.in)
if err != nil {
t.Errorf("normalizeTimestamp(%q) err=%v", c.in, err)
continue
}
ts, perr := time.Parse(time.RFC3339, got)
if perr != nil {
t.Errorf("normalizeTimestamp(%q)=%q not RFC3339", c.in, got)
continue
}
want := time.Now().Add(-c.dur)
if diff := want.Sub(ts); diff > 5*time.Second || diff < -5*time.Second {
t.Errorf("normalizeTimestamp(%q)=%q off by %v from now-%v", c.in, got, diff, c.dur)
}
}
// 非法格式 → error
for _, bad := range []string{"notatime", "7x", "2026/04/15", "2026-13-99"} {
if _, err := normalizeTimestamp(bad); err == nil {
t.Errorf("normalizeTimestamp(%q) expected error", bad)
}
}
}
const fileListURL = "/open-apis/spark/v1/apps/app_x/storage/file_list"
// TestAppsFileList_RequiresAppID 验证空白 --app-id 触发 --app-id typed 校验错误。
func TestAppsFileList_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", " ", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--app-id" {
t.Fatalf("Param = %q, want --app-id", ve.Param)
}
}
// 过滤器 + 分页全部进 querysize-gt/lt 走 intuploaded_since/until 原样)。
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x",
"--name", "logo.png", "--path", "/x.png", "--type", "image/png",
"--size-gt", "100", "--size-lt", "9000",
"--uploaded-since", "2026-01-01", "--uploaded-until", "2026-02-01",
"--page-size", "5", "--page-token", "cur-1",
"--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
a := env.API[0]
if a.Method != "GET" || a.URL != fileListURL {
t.Fatalf("method/url = %s %s", a.Method, a.URL)
}
// 设计原则三date 入参会被归一化为 RFC3339 UTC期望值用 normalizeTimestamp 计算(避开本地时区脆弱断言)。
sinceN, _ := normalizeTimestamp("2026-01-01")
untilN, _ := normalizeTimestamp("2026-02-01")
wantStr := map[string]string{
"name": "logo.png", "path": "/x.png", "type": "image/png",
"uploaded_since": sinceN, "uploaded_until": untilN, "page_token": "cur-1",
}
for k, v := range wantStr {
if a.Params[k] != v {
t.Errorf("params.%s = %v, want %v", k, a.Params[k], v)
}
}
// 且确实归一化成了 UTC以 Z 结尾),不是原样透传。
if s, _ := a.Params["uploaded_since"].(string); !strings.HasSuffix(s, "Z") {
t.Errorf("uploaded_since not normalized to RFC3339 UTC: %v", a.Params["uploaded_since"])
}
for _, k := range []string{"size_gt", "size_lt", "page_size"} {
if _, ok := a.Params[k]; !ok {
t.Errorf("params missing %s: %v", k, a.Params)
}
}
}
// 0 值过滤器不下发size-gt/lt 缺省 0、空字符串过滤器
func TestAppsFileList_DryRunOmitsEmptyFilters(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
for _, banned := range []string{"name", "path", "type", "size_gt", "size_lt", "uploaded_since", "uploaded_until", "page_token"} {
if _, ok := env.API[0].Params[banned]; ok {
t.Errorf("params should omit empty %s: %v", banned, env.API[0].Params)
}
}
if _, ok := env.API[0].Params["page_size"]; !ok {
t.Errorf("params should always carry page_size: %v", env.API[0].Params)
}
}
// created_at/created_by → uploaded_at/uploaded_bycreated_by 是 JSON 字符串 → parse 成对象。
func TestAppsFileList_SuccessProjectsCreatedToUploaded(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: fileListURL,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{
"file_name": "logo.png",
"path": "/1858537546760216.png",
"size_bytes": 24580,
"type": "image/png",
"created_at": "2026-04-15T10:30:00Z",
"created_by": `{"id":"7311","name":"alice"}`,
"download_url": "/spark/app/x/1858537546760216.png",
},
},
},
},
})
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{`"uploaded_at": "2026-04-15T10:30:00Z"`, `"uploaded_by"`, `"name": "alice"`, `"id": "7311"`} {
if !strings.Contains(got, want) {
t.Errorf("stdout missing %q:\n%s", want, got)
}
}
// created_* 不应再出现在输出。
for _, banned := range []string{"created_at", "created_by"} {
if strings.Contains(got, banned) {
t.Errorf("stdout should not contain %q (renamed to uploaded_*):\n%s", banned, got)
}
}
}
// TestAppsFileList_PrettyTableAndEmpty 验证 pretty 非空时渲染表头与人类可读 size空结果时输出 "No files found."。
func TestAppsFileList_PrettyTableAndEmpty(t *testing.T) {
// 非空5 列表头。
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: fileListURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{
"file_name": "logo.png", "path": "/x.png", "size_bytes": 24576, "type": "image/png",
"created_at": "2026-04-15T10:30:00Z",
}},
}},
})
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "file_name") || !strings.Contains(got, "uploaded_at") || !strings.Contains(got, "24 KB") {
t.Fatalf("pretty table malformed:\n%s", got)
}
// 空No files found.
factory2, stdout2, reg2 := newAppsExecuteFactory(t)
reg2.Register(&httpmock.Stub{
Method: "GET", URL: fileListURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
})
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout2.String(), "No files found.") {
t.Fatalf("empty pretty should say 'No files found.', got: %s", stdout2.String())
}
}
// TestParseFileUser_Cases 验证 parseFileUser合法 JSON 解析成对象,空串/非法/全空字段均返回 nil。
func TestParseFileUser_Cases(t *testing.T) {
if u := parseFileUser(`{"id":"1","name":"a"}`); u == nil || u.ID != "1" || u.Name != "a" {
t.Fatalf("valid parse failed: %#v", u)
}
if u := parseFileUser(""); u != nil {
t.Errorf("empty → nil, got %#v", u)
}
if u := parseFileUser("not json"); u != nil {
t.Errorf("invalid → nil, got %#v", u)
}
if u := parseFileUser(`{"id":"","name":""}`); u != nil {
t.Errorf("all-empty → nil, got %#v", u)
}
}

View File

@@ -1,93 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsFileQuotaGet reports an app's file-storage usage动词对齐 +db-quota-get
//
// GET /apps/{app_id}/storage/file_quota。storage_quota_bytes / usage_percent 在配额未对接(=0
// 不输出json 删字段、pretty 只打已用量)。
var AppsFileQuotaGet = common.Shortcut{
Service: appsService,
Command: "+file-quota-get",
Description: "Get an app's file-storage usage",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +file-quota-get --app-id <app_id>",
"Tip: get just the usage percent with -q '.usage_percent'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appFileQuotaPath(appID)).
Desc("Get Miaoda app file-storage usage")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appFileQuotaPath(appID), nil, nil)
if err != nil {
return err
}
out := projectFileQuota(data)
rctx.OutFormat(out, nil, func(w io.Writer) {
renderFileQuotaPretty(w, out)
})
return nil
},
}
// projectFileQuota 白名单投影 file quota 字段:只保留 agent 需要的 storage_used_bytes / files
// 配额已对接时再加 storage_quota_bytes / usage_percent。不透传后端其它字段避免无用字段消耗上下文。
func projectFileQuota(data map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{"storage_used_bytes": data["storage_used_bytes"]}
if v, ok := data["files"]; ok {
out["files"] = v
}
// 配额未对接storage_quota_bytes=0/缺失)时不输出 quota / usage_percent避免误导。
if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 {
out["storage_quota_bytes"] = data["storage_quota_bytes"]
if v, ok := data["usage_percent"]; ok {
out["usage_percent"] = v
}
}
return out
}
// renderFileQuotaPretty 打 Storage已用 / 配额 (百分比))与 Files 行(标签对齐 miaoda-cli
func renderFileQuotaPretty(w io.Writer, data map[string]interface{}) {
used := humanBytes(data["storage_used_bytes"])
usage := used
if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 {
pct := ""
if p, ok := numericAsFloat(data["usage_percent"]); ok {
pct = fmt.Sprintf(" (%.1f%%)", p)
}
usage = fmt.Sprintf("%s / %s%s", used, humanBytes(data["storage_quota_bytes"]), pct)
}
pairs := [][2]string{{"Storage", usage}}
if f, ok := numericAsFloat(data["files"]); ok {
pairs = append(pairs, [2]string{"Files", fmt.Sprintf("%d", int64(f))})
}
renderKeyValuePairs(w, pairs)
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
const fileQuotaURL = "/open-apis/spark/v1/apps/app_x/storage/file_quota"
// TestAppsFileQuotaGet_QuotaConnectedShowsAllFields 验证配额已对接时输出 storage_quota_bytes/usage_percent/files 全字段。
func TestAppsFileQuotaGet_QuotaConnectedShowsAllFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: fileQuotaURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"storage_used_bytes": 157286400,
"storage_quota_bytes": 1073741824,
"usage_percent": 14.6,
"files": 42,
}},
})
if err := runAppsShortcut(t, AppsFileQuotaGet,
[]string{"+file-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{`"storage_quota_bytes"`, `"usage_percent"`, `"files"`} {
if !strings.Contains(got, want) {
t.Errorf("quota json missing %q:\n%s", want, got)
}
}
}
// 配额未对接(=0storage_quota_bytes / usage_percent 不输出。
func TestAppsFileQuotaGet_UnconnectedOmitsQuotaFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: fileQuotaURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"storage_used_bytes": 157286400,
"storage_quota_bytes": 0,
"usage_percent": 0,
"files": 42,
}},
})
if err := runAppsShortcut(t, AppsFileQuotaGet,
[]string{"+file-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, banned := range []string{"storage_quota_bytes", "usage_percent"} {
if strings.Contains(got, banned) {
t.Errorf("unconnected quota should omit %q:\n%s", banned, got)
}
}
if !strings.Contains(got, `"storage_used_bytes"`) || !strings.Contains(got, `"files"`) {
t.Errorf("should still show used/files:\n%s", got)
}
}
// TestProjectFileQuota_OmitsZeroQuotaAndDropsUnknownFields 验证 projectFileQuota 白名单投影:
// quota=0 时不输出 storage_quota_bytes/usage_percent非零时保留后端额外字段不透传。
func TestProjectFileQuota_OmitsZeroQuotaAndDropsUnknownFields(t *testing.T) {
out := projectFileQuota(map[string]interface{}{
"storage_used_bytes": 100, "storage_quota_bytes": float64(0), "usage_percent": float64(0),
"files": 3, "tenant_key": "leak", "request_id": "rid",
})
if _, ok := out["storage_quota_bytes"]; ok {
t.Errorf("zero quota should be omitted: %v", out)
}
if _, ok := out["usage_percent"]; ok {
t.Errorf("usage_percent should be omitted when quota=0: %v", out)
}
if out["storage_used_bytes"] != 100 || out["files"] != 3 {
t.Errorf("whitelisted fields should be kept: %v", out)
}
// 白名单外的字段必须被丢弃,避免无用字段消耗 agent 上下文。
for _, leaked := range []string{"tenant_key", "request_id"} {
if _, ok := out[leaked]; ok {
t.Errorf("non-whitelisted field %q must be dropped: %v", leaked, out)
}
}
out2 := projectFileQuota(map[string]interface{}{"storage_used_bytes": 100, "storage_quota_bytes": float64(1024), "usage_percent": float64(9.8), "files": 3})
if _, ok := out2["storage_quota_bytes"]; !ok {
t.Errorf("non-zero quota should be kept: %v", out2)
}
if _, ok := out2["usage_percent"]; !ok {
t.Errorf("usage_percent should be kept when quota>0: %v", out2)
}
}

View File

@@ -1,82 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// fileSignMaxExpiresSeconds 是签名链接最长有效期30 天)。超出 → 校验失败。
const fileSignMaxExpiresSeconds = 30 * 24 * 60 * 60
// AppsFileSign generates a temporary signed download URL for a file。
//
// POST /apps/{app_id}/storage/file_signbody {path, expires_in}。
// pretty 模式只打 signed_url便于直接管道 / curljson 返 {file_name,path,signed_url,expires_at}。
var AppsFileSign = common.Shortcut{
Service: appsService,
Command: "+file-sign",
Description: "Generate a temporary signed download URL for a file",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +file-sign --app-id <app_id> --path /1858537546760216.png",
"Tip: curl the signed_url directly to download.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "path", Desc: "remote file path", Required: true},
{Name: "expires-in", Type: "int", Default: "86400", Desc: "link validity in seconds (max 2592000 = 30d)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if _, err := requireFilePath(rctx.Str("path")); err != nil {
return err
}
if rctx.Int("expires-in") > fileSignMaxExpiresSeconds {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--expires-in exceeds the maximum of %d seconds (30d)", fileSignMaxExpiresSeconds).WithParam("--expires-in")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appFileSignPath(appID)).
Desc("Sign a temporary download URL").
Body(buildFileSignBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", appFileSignPath(appID), nil, buildFileSignBody(rctx))
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintln(w, common.GetString(data, "signed_url"))
})
return nil
},
}
// buildFileSignBody 组装 file_sign 请求体path 及可选 expires_in
func buildFileSignBody(rctx *common.RuntimeContext) map[string]interface{} {
path, _ := requireFilePath(rctx.Str("path"))
body := map[string]interface{}{"path": path}
if v := rctx.Int("expires-in"); v > 0 {
body["expires_in"] = v
}
return body
}

View File

@@ -1,74 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const fileSignURL = "/open-apis/spark/v1/apps/app_x/storage/file_sign"
// TestAppsFileSign_DryRunBody 验证 dry-run 输出 POST file_signbody 携带 path 与 expires_in。
func TestAppsFileSign_DryRunBody(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileSign,
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "3600", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != fileSignURL || a.Body["path"] != "/x.png" {
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
}
if ei, _ := a.Body["expires_in"].(float64); int(ei) != 3600 {
t.Fatalf("body.expires_in = %v, want 3600", a.Body["expires_in"])
}
}
// TestAppsFileSign_RejectsDurationOverMax 验证 --expires-in 超过上限时触发 --expires-in typed 校验错误。
func TestAppsFileSign_RejectsDurationOverMax(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileSign,
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "9999999", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--expires-in" {
t.Fatalf("Param = %q, want --expires-in", ve.Param)
}
}
// TestAppsFileSign_PrettyPrintsSignedURL 验证 pretty 只输出 signed_url 本身。
func TestAppsFileSign_PrettyPrintsSignedURL(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: fileSignURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "x.png", "path": "/x.png",
"signed_url": "https://tos.example/x.png?sig=abc", "expires_at": "2026-04-16T10:30:00Z",
}},
})
if err := runAppsShortcut(t, AppsFileSign,
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := strings.TrimSpace(stdout.String())
if got != "https://tos.example/x.png?sig=abc" {
t.Fatalf("pretty should print only signed_url, got: %q", got)
}
}

View File

@@ -1,206 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
// fileUploadMaxBytes 是单文件上传上限100 MB对齐 miaoda
const fileUploadMaxBytes = 100 * 1024 * 1024
// AppsFileUpload uploads a local file to an app's storage三步直传
//
// 1. POST /apps/{app_id}/storage/file_pre_upload {file_name,file_size,content_type} → {upload_url,upload_id}
// 2. 客户端 PUT 文件字节到 presigned upload_url取响应 ETag
// 3. POST /apps/{app_id}/storage/file_upload_callback {upload_id,etag} → 文件元数据
// file_name 取本地 basenamepath 由平台生成 16 位 ID不可指定。仅收 --file。
var AppsFileUpload = common.Shortcut{
Service: appsService,
Command: "+file-upload",
Description: "Upload a local file to an app's storage",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +file-upload --app-id <app_id> --file ./logo.png",
"Example: lark-cli apps +file-upload --app-id <app_id> --file ./report.pdf -q '.path' # print the platform-generated file path",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "file", Desc: "local file to upload (file_name = basename)", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
f := strings.TrimSpace(rctx.Str("file"))
if f == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
st, err := rctx.FileIO().Stat(f)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
if st.IsDir() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
}
if st.Size() > fileUploadMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appFilePreUploadPath(appID)).
Desc("Pre-upload → client PUT bytes → callback (3-step)").
Body(map[string]interface{}{"file_name": filepath.Base(strings.TrimSpace(rctx.Str("file")))})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
localPath := strings.TrimSpace(rctx.Str("file"))
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
fileName := filepath.Base(localPath)
contentType := mimeByExt(fileName)
// 1. pre-upload
pre, err := rctx.CallAPITyped("POST", appFilePreUploadPath(appID), nil, map[string]interface{}{
"file_name": fileName,
"file_size": len(content),
"content_type": contentType,
})
if err != nil {
return err
}
uploadURL := common.GetString(pre, "upload_url")
uploadID := common.GetString(pre, "upload_id")
if uploadURL == "" || uploadID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "pre-upload returned no upload_url / upload_id")
}
// 2. PUT 文件字节到 presigned URL取 ETag带 Content-Disposition 透传原始文件名)
etag, err := putFileBytes(rctx.Ctx(), uploadURL, content, contentType, fileName)
if err != nil {
return err
}
// 3. callback
result, err := rctx.CallAPITyped("POST", appFileUploadCallbackPath(appID), nil, map[string]interface{}{
"upload_id": uploadID,
"etag": etag,
})
if err != nil {
return err
}
info := projectFileInfo(result)
rctx.OutFormat(info, nil, func(w io.Writer) {
renderFileUploadPretty(w, fileName, info)
})
return nil
},
}
// putFileBytes 直连 PUT 文件字节到 presigned URL返回响应的 ETag。
//
// Content-Disposition 透传原始文件名TOS 把它存成对象 metadatacallback 阶段后端
// HeadObject 读回解析出 filename 写入 DB 的 display name。不传则后端兜底用 storage key
// (平台 16 位 ID当文件名 —— 即「上传后文件名变成 ID」的根因。
//
//nolint:forbidigo // direct PUT to a presigned object-storage URL bypasses the Lark gateway — raw HTTP is required (no Lark auth/gateway); RuntimeContext.DoAPI cannot target a presigned URL.
func putFileBytes(ctx context.Context, url string, content []byte, contentType, fileName string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(content))
if err != nil {
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "build upload request").WithCause(err)
}
req.ContentLength = int64(len(content))
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("Content-Disposition", "attachment; filename=\""+sanitizeUploadFileName(fileName)+"\"")
resp, err := newFileTransferClient().Do(req)
if err != nil {
// dial/transport 失败是典型可重试场景。
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed").WithCause(err).WithRetryable()
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 400 {
// 5xx 是上游瞬时故障,标 retryable4xx如签名过期需重新签名而非盲重试不标。
if resp.StatusCode >= 500 {
return "", errs.NewNetworkError(errs.SubtypeNetworkServer, "upload failed: HTTP %d", resp.StatusCode).WithRetryable()
}
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed: HTTP %d", resp.StatusCode)
}
return resp.Header.Get("ETag"), nil
}
// sanitizeUploadFileName 对齐 miaoda先去掉 TOS 非法字符 [:"\/*?<>|,;],再 encodeURIComponent
// UTF-8 百分号编码,兼容中文等非 ASCII且让 Content-Disposition header 合法),空则兜底 download_file。
func sanitizeUploadFileName(name string) string {
var b strings.Builder
for _, r := range name {
switch r {
case ':', '"', '\\', '/', '*', '?', '<', '>', '|', ',', ';':
continue
default:
b.WriteRune(r)
}
}
enc := encodeURIComponent(b.String())
if enc == "" {
return "download_file"
}
return enc
}
// encodeURIComponent 复刻 JS encodeURIComponent除 A-Za-z0-9-_.!~*'() 外按 UTF-8 字节 %XX 编码。
func encodeURIComponent(s string) string {
const keep = "-_.!~*'()"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || strings.IndexByte(keep, c) >= 0 {
b.WriteByte(c)
} else {
b.WriteString(fmt.Sprintf("%%%02X", c))
}
}
return b.String()
}
// mimeByExt 按扩展名推断 Content-Type未知回退 application/octet-stream。
func mimeByExt(name string) string {
if t := mime.TypeByExtension(filepath.Ext(name)); t != "" {
return t
}
return "application/octet-stream"
}
// renderFileUploadPretty 打 ✓ Uploaded <local> → <path> + size / download_url。
func renderFileUploadPretty(w io.Writer, localName string, info fileInfo) {
fmt.Fprintf(w, "✓ Uploaded %s → %s\n", localName, info.Path)
fmt.Fprintf(w, "size: %s\n", fileSizeDetail(info.SizeBytes))
if info.DownloadURL != "" {
fmt.Fprintf(w, "download_url: %s\n", info.DownloadURL)
}
}

View File

@@ -1,179 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// TestAppsFileUpload_RequiresAppIDAndFile 验证仅含空白的 --file 经 Validate 去空后触发 --file typed 校验错误。
func TestAppsFileUpload_RequiresAppIDAndFile(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
// --file is a cobra-required flag; pass whitespace so cobra's required check
// passes and our Validate (which trims) rejects it with a typed error.
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", " ", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--file" {
t.Fatalf("Param = %q, want --file", ve.Param)
}
}
// TestAppsFileUpload_RejectsDirectory 验证 --file 指向目录时触发 --file typed 校验错误。
func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
dir := t.TempDir()
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatal(err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "sub", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--file" {
t.Fatalf("Param = %q, want --file", ve.Param)
}
}
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_uploadbody.file_name 取文件 basename。
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
// Validate 会 Stat --file在 DryRun 之前),故 dry-run 也需要真实存在的文件。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload" {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Body["file_name"] != "logo.png" {
t.Fatalf("dry-run body.file_name = %v, want logo.png (basename)", a.Body["file_name"])
}
}
// 三步直传pre-upload → 客户端 PUT 字节 → callback。
func TestAppsFileUpload_EndToEnd(t *testing.T) {
var putBody []byte
var putContentType, putCD string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
putBody, _ = io.ReadAll(r.Body)
putContentType = r.Header.Get("Content-Type")
putCD = r.Header.Get("Content-Disposition")
w.Header().Set("ETag", `"etag-123"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("PNGBYTES"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-1"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "logo.png", "path": "/1858537546760216.png", "size_bytes": 8, "type": "image/png",
"download_url": "/spark/app/x/1858537546760216.png",
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if string(putBody) != "PNGBYTES" {
t.Fatalf("PUT body = %q, want file bytes", putBody)
}
if putContentType != "image/png" {
t.Errorf("PUT Content-Type = %q, want image/png", putContentType)
}
// 原始文件名必须经 Content-Disposition 透传给 TOS否则后端用 storage key 当文件名)。
if putCD != `attachment; filename="logo.png"` {
t.Errorf("PUT Content-Disposition = %q, want attachment; filename=\"logo.png\"", putCD)
}
got := stdout.String()
if !strings.Contains(got, `"path": "/1858537546760216.png"`) {
t.Errorf("output missing uploaded path:\n%s", got)
}
}
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
func TestSanitizeUploadFileName_Cases(t *testing.T) {
cases := []struct{ in, want string }{
{"logo.png", "logo.png"},
{"a b.png", "a%20b.png"}, // 空格 → %20encodeURIComponent
{`a:b/c*d?.png`, "abcd.png"}, // 去掉 TOS 非法字符
{"///", "download_file"}, // 全非法 → 兜底
{"中.txt", "%E4%B8%AD.txt"}, // 非 ASCII → UTF-8 百分号编码
}
for _, c := range cases {
if got := sanitizeUploadFileName(c.in); got != c.want {
t.Errorf("sanitizeUploadFileName(%q)=%q want %q", c.in, got, c.want)
}
}
}
// TestMimeByExt_Cases 验证 mimeByExt按扩展名识别 image/png未知扩展名兜底 application/octet-stream。
func TestMimeByExt_Cases(t *testing.T) {
if got := mimeByExt("a.png"); !strings.HasPrefix(got, "image/png") {
t.Errorf("mimeByExt(a.png)=%q want image/png", got)
}
if got := mimeByExt("data.unknownext"); got != "application/octet-stream" {
t.Errorf("mimeByExt(unknown)=%q want application/octet-stream", got)
}
}

View File

@@ -1,67 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"regexp"
"testing"
)
// TestAppsErrorHintsCarryNoSecretsOrPII guards the actionable error hints added
// for the apps command-governance task. Those hints are inline string literals
// spread across several files (apps_env_pull.go, apps_access_scope_set.go,
// apps_access_scope_get.go, apps_init.go git-push path, and the
// gitCredentialIssueHint const in git_credential.go). They are stable English
// strings, so we assert the verbatim copies here: a real app_id, an email, or a
// phone number must never appear in a hint. Placeholders like <app_id> are
// expected and must NOT trip the real-app-id regex.
func TestAppsErrorHintsCarryNoSecretsOrPII(t *testing.T) {
// These are copied verbatim from the source. If a hint changes, copy the new
// text here so this leak guard keeps tracking the real production string.
hints := []string{
// apps_env_pull.go:86 and apps_access_scope_get.go:50 (identical literals)
"verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`",
// apps_access_scope_set.go:74
"verify --app-id is correct; for scope=specific, each --targets id must be a valid open_id/department_id/chat_id and --approver a valid open_id; review the current scope with `lark-cli apps +access-scope-get --app-id <app_id>`",
// apps_init.go:483 (git push rejection)
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded",
// git_credential.go gitCredentialIssueHint const (referenced directly so a
// rename or text change breaks the build instead of silently drifting)
gitCredentialIssueHint,
// command-governance hints added for this task (referenced by const, no drift)
appIDListHint,
sessionStopHint,
createHint,
dbEnvCreateHint,
dbTableGetHint,
dbTableListHint,
}
realAppID := regexp.MustCompile(`app_[a-z0-9]{6,}`)
email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`)
// An obvious secret: a PAT-like token or a "secret=..." / "token=..." pair.
secret := regexp.MustCompile(`(?i)(pat-[a-z0-9]+|secret\s*[=:]\s*\S|token\s*[=:]\s*\S)`)
for _, h := range hints {
if realAppID.MatchString(h) {
t.Errorf("hint leaks a real-looking app id (use <app_id>): %q", h)
}
if email.MatchString(h) {
t.Errorf("hint leaks an email address: %q", h)
}
if phone.MatchString(h) {
t.Errorf("hint leaks a phone number: %q", h)
}
if secret.MatchString(h) {
t.Errorf("hint leaks an obvious secret/token: %q", h)
}
}
// Sanity: the placeholder <app_id> must NOT match the real-app-id regex,
// otherwise the guard above would be a false positive on legitimate hints.
if realAppID.MatchString("<app_id>") {
t.Fatal("realAppID regex incorrectly matches the <app_id> placeholder")
}
}

View File

@@ -1,129 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func assertHintContains(t *testing.T, sc common.Shortcut, args []string, stub *httpmock.Stub, want string) {
t.Helper()
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(stub)
err := runAppsShortcut(t, sc, args, factory, stdout)
if err == nil {
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
if !strings.Contains(p.Hint, want) {
t.Fatalf("hint %q does not contain %q", p.Hint, want)
}
}
func TestAppsSessionCreate_4xxFailureCarriesListHint(t *testing.T) {
assertHintContains(t, AppsSessionCreate,
[]string{"+session-create", "--app-id", "app_x", "--as", "user"},
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/sessions",
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "app not found"}},
"apps +list")
}
func TestAppsSessionList_4xxFailureCarriesListHint(t *testing.T) {
assertHintContains(t, AppsSessionList,
[]string{"+session-list", "--app-id", "app_x", "--as", "user"},
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/sessions",
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
"apps +list")
}
func TestAppsUpdate_4xxFailureCarriesListHint(t *testing.T) {
assertHintContains(t, AppsUpdate,
[]string{"+update", "--app-id", "app_x", "--name", "n", "--as", "user"},
&httpmock.Stub{Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x",
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "app not found"}},
"apps +list")
}
func TestAppsReleaseList_4xxFailureCarriesListHint(t *testing.T) {
assertHintContains(t, AppsReleaseList,
[]string{"+release-list", "--app-id", "app_x", "--as", "user"},
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases",
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
"apps +list")
}
func TestAppsSessionStop_4xxFailureCarriesSessionHint(t *testing.T) {
assertHintContains(t, AppsSessionStop,
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "s1", "--turn-id", "t1", "--as", "user"},
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/sessions/s1/stop",
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "session not found"}},
"+session-list")
}
func TestAppsCreate_4xxFailureCarriesTypeHint(t *testing.T) {
assertHintContains(t, AppsCreate,
[]string{"+create", "--name", "n", "--app-type", "html", "--as", "user"},
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps",
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
"full_stack")
}
func TestAppsDBEnvCreate_4xxFailureCarriesHint(t *testing.T) {
assertHintContains(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"},
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
Status: http.StatusConflict, Body: map[string]interface{}{"msg": "already multi-env"}},
"+db-table-list")
}
func TestAppsDBTableGet_4xxFailureCarriesHint(t *testing.T) {
assertHintContains(t, AppsDBTableGet,
[]string{"+db-table-get", "--app-id", "app_x", "--table", "users", "--as", "user"},
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables/users",
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "table not found"}},
"+db-table-list")
}
func TestAppsDBTableList_4xxFailureCarriesHint(t *testing.T) {
assertHintContains(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"},
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables",
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "dev env not found"}},
"+db-env-create")
}
// withAppsHint must only fill an EMPTY hint; an upstream-provided hint wins.
func TestWithAppsHint_DoesNotOverrideUpstreamHint(t *testing.T) {
upstream := &errs.Problem{Message: "boom", Hint: "upstream specific hint"}
got := withAppsHint(upstream, appIDListHint)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T", got)
}
if p.Hint != "upstream specific hint" {
t.Fatalf("upstream hint was overridden: %q", p.Hint)
}
}
// withAppsHint fills the hint when empty and leaves Message untouched.
func TestWithAppsHint_FillsEmptyHintKeepsMessage(t *testing.T) {
p0 := &errs.Problem{Message: "boom"}
got := withAppsHint(p0, appIDListHint)
p, _ := errs.ProblemOf(got)
if p.Hint != appIDListHint {
t.Fatalf("hint not filled: %q", p.Hint)
}
if p.Message != "boom" {
t.Fatalf("message mutated: %q", p.Message)
}
}

View File

@@ -1,91 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// TestAppsEnvPull_4xxFailureCarriesListHint verifies that a 4xx failure from the
// env_vars endpoint surfaces an actionable hint pointing at `lark-cli apps +list`.
func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Status: http.StatusForbidden,
Body: map[string]interface{}{"msg": "permission denied"},
})
err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
if !strings.Contains(p.Hint, "apps +list") {
t.Fatalf("hint missing `apps +list`: %q", p.Hint)
}
}
// TestAppsAccessScopeGet_4xxFailureCarriesListHint verifies the access-scope-get
// 4xx failure points at `lark-cli apps +list`.
func TestAppsAccessScopeGet_4xxFailureCarriesListHint(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Status: http.StatusNotFound,
Body: map[string]interface{}{"msg": "app not found"},
})
err := runAppsShortcut(t, AppsAccessScopeGet,
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
if !strings.Contains(p.Hint, "apps +list") {
t.Fatalf("hint missing `apps +list`: %q", p.Hint)
}
}
// TestAppsAccessScopeSet_4xxFailureCarriesScopeGetHint verifies the
// access-scope-set 4xx failure points at `+access-scope-get`.
func TestAppsAccessScopeSet_4xxFailureCarriesScopeGetHint(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Status: http.StatusBadRequest,
Body: map[string]interface{}{"msg": "invalid target id"},
})
err := runAppsShortcut(t, AppsAccessScopeSet,
[]string{"+access-scope-set", "--app-id", "app_x", "--scope", "tenant", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
if !strings.Contains(p.Hint, "+access-scope-get") {
t.Fatalf("hint missing `+access-scope-get`: %q", p.Hint)
}
}

View File

@@ -21,13 +21,9 @@ var AppsHTMLPublish = common.Shortcut{
Command: "+html-publish",
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./dist",
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./site --dry-run",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "path", Desc: "path to HTML file or directory", Required: true},

View File

@@ -1,674 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"unicode"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// defaultInitBranch is the fixed remote branch +init checks out after clone.
const defaultInitBranch = "sprint/default"
// Fixed init commit subjects. Constants — never interpolate user input. The
// empty-repo (`app init`) path splits the scaffolded tree into two commits;
// the non-empty (`app sync`) path stays a single commit.
const (
commitMsgAppCode = "chore: initialize app project code"
commitMsgAppConfig = "chore: initialize miaoda app config"
commitMsgUpgrade = "chore: initialize miaoda app repository"
)
// scaffold kinds returned by runScaffold and consumed by commitAndPushIfDirty.
const (
scaffoldKindInit = "init"
scaffoldKindUpgrade = "upgrade"
)
const (
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
defaultTemplate = "nestjs-react-fullstack"
metaRelPath = ".spark/meta.json"
steeringRelPath = ".agent/skills/steering"
seedReadme = "README.md"
)
// initRunner is the commandRunner used by +init. Package-level so unit tests
// can swap in a fakeCommandRunner. Production uses execCommandRunner.
var initRunner commandRunner = execCommandRunner{}
// AppsInit initializes a Miaoda app's code and local development environment.
var AppsInit = common.Shortcut{
Service: appsService,
Command: "+init",
Description: "Initialize a Miaoda app's code and local development environment",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +init --app-id <app_id> --dir <dir>",
"Example: lark-cli apps +init --app-id <app_id> --dir <dir> --dry-run",
},
// +init makes no direct lark API calls (it shells out to the
// +git-credential-init subprocess, which enforces its own scopes), so it
// declares no scopes of its own. Explicit []string{} (not nil) per the
// convention enforced by TestAllShortcutsScopesNotNil.
Scopes: []string{},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
// NOTE: --app-id is intentionally NOT Required:true. The framework maps
// Required:true to cobra's MarkFlagRequired, whose error is plain-text
// exit-1 (root.go handleRootError case 4), bypassing the structured
// envelope. The spec and the E2E assert exit-2 + a structured
// {"ok":false,"error":{...}} envelope for missing --app-id, so the empty
// check lives in Validate (output.ErrValidation -> ExitValidation=2).
{Name: "app-id", Desc: "Miaoda app ID"},
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
template := resolveTemplate(rctx, appID)
dry := common.NewDryRunAPI().
Desc("Initialize Miaoda app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
Set("checkout", "git checkout "+defaultInitBranch).
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
Set("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes").
Set("template", template).
Set("env_pull", fmt.Sprintf("apps +env-pull --app-id %s --project-path <clone_path> --format json (after successful init)", appID))
dir, err := resolveTargetPath(rctx, appID)
if err != nil {
dry.Set("dir_error", err.Error())
dir = defaultCloneDir(appID)
} else if isAlreadyInitialized(dir) {
dry.Set("already_initialized", true)
} else if e := ensureEmptyDir(dir); e != nil {
dry.Set("dir_error", e.Error())
}
dry.Set("clone", fmt.Sprintf("git clone -- <repository_url-from-credential-init> %s", dir))
dry.Set("clone_path", dir)
return dry
},
Execute: appsInitExecute,
}
// defaultCloneDir returns the default clone target (./<app-id>) for an app ID.
func defaultCloneDir(appID string) string {
return filepath.Join(".", appID)
}
// resolveTemplate returns the scaffold template for an empty-repo `app init`.
// An explicit --template wins. When omitted, it should be derived from the
// app's tech stack.
// TODO(apps-init): look up the app by appID via the apps API (e.g. `apps +list`
// or a get-app endpoint), read its tech stack, and map tech-stack -> template
// through a (future) enum. Until that lands, fall back to defaultTemplate.
func resolveTemplate(rctx *common.RuntimeContext, appID string) string {
if t := strings.TrimSpace(rctx.Str("template")); t != "" {
return t
}
// TODO(apps-init): derive from app tech stack (apps API + enum mapping).
return defaultTemplate
}
// initLogf writes a one-line progress message to stderr. stdout stays reserved
// for the structured JSON envelope, so progress never pollutes it. Callers must
// never pass a raw repository_url (it may embed a token) — pass step names,
// clone_path, branch, or scaffold kind, and route any URL through
// redactURLCredentials first.
func initLogf(rctx *common.RuntimeContext, format string, args ...interface{}) {
fmt.Fprintf(rctx.IO().ErrOut, "→ "+format+"\n", args...)
}
// resolveTargetPath computes the absolute clone target from --dir (or the
// ./<app-id> default). Unlike the prior SafeInputPath approach it does NOT
// confine to cwd — the clone destination is user-chosen (the skill prompts for
// it). It rejects empty input and control characters; symlink/no-clobber
// guarding happens in ensureEmptyDir.
func resolveTargetPath(rctx *common.RuntimeContext, appID string) (string, error) {
raw := strings.TrimSpace(rctx.Str("dir"))
if raw == "" {
raw = defaultCloneDir(appID)
}
// Reject ALL control characters (incl. tab/newline — a newline in an echoed
// path is a log-injection vector); charcheck additionally rejects dangerous
// Unicode (bidi overrides, zero-width) that IsControl does not.
if strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return "", output.ErrValidation("--dir must not contain control characters")
}
if err := charcheck.RejectControlChars(raw, "--dir"); err != nil {
return "", output.ErrValidation("%v", err)
}
abs, err := filepath.Abs(raw) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); raw is control-char-validated above, and FileIO.ResolvePath cannot resolve a clone target (it rejects absolute paths).
if err != nil {
return "", output.ErrValidation("--dir cannot be resolved: %v", err)
}
return abs, nil
}
// ensureEmptyDir refuses to clone into an existing non-empty dir, a symlink, or
// a non-directory. A non-existent path is fine (git clone creates it). Uses
// Lstat so a symlinked target is rejected rather than followed.
func ensureEmptyDir(dir string) error {
info, err := os.Lstat(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and lstat is required to reject a symlink (FileIO has no Lstat; its Stat follows symlinks).
if os.IsNotExist(err) {
return nil
}
if err != nil {
return output.ErrValidation("--dir cannot be read: %v", err)
}
if info.Mode()&os.ModeSymlink != 0 {
return output.ErrValidation("--dir must not be a symlink: %q", dir)
}
if !info.IsDir() {
return output.ErrValidation("--dir exists and is not a directory: %q", dir)
}
entries, err := os.ReadDir(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and FileIO has no ReadDir.
if err != nil {
return output.ErrValidation("--dir cannot be read: %v", err)
}
if len(entries) > 0 {
return output.ErrValidation("target directory %q already exists and is not empty", dir)
}
return nil
}
// isAlreadyInitialized reports whether dir is an already-initialized Miaoda app
// repo, detected by the presence of <dir>/.spark/meta.json (regardless of its
// app_id value). Used to short-circuit +init into a friendly no-op.
func isAlreadyInitialized(dir string) bool {
info, err := os.Stat(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths.
return err == nil && !info.IsDir()
}
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
// the file does not exist, this is a no-op (we never create it).
func ensureMetaAppID(dir, appID string) error {
path := filepath.Join(dir, metaRelPath)
b, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
if os.IsNotExist(err) {
return nil
}
if err != nil {
return output.Errorf(output.ExitAPI, "meta_write", "read %s failed: %v", metaRelPath, err)
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
return output.Errorf(output.ExitAPI, "meta_write", "parse %s failed: %v", metaRelPath, err)
}
if cur, _ := m["app_id"].(string); strings.TrimSpace(cur) != "" {
return nil
}
if m == nil {
m = map[string]interface{}{}
}
m["app_id"] = appID
out, err := json.MarshalIndent(m, "", " ")
if err != nil {
return output.Errorf(output.ExitAPI, "meta_write", "marshal %s failed: %v", metaRelPath, err)
}
if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Save rejects absolute paths.
return output.Errorf(output.ExitAPI, "meta_write", "write %s failed: %v", metaRelPath, err)
}
return nil
}
// hasSteeringSkills reports whether <dir>/.agent/skills/steering exists as a dir.
func hasSteeringSkills(dir string) bool {
info, err := os.Stat(filepath.Join(dir, steeringRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths.
return err == nil && info.IsDir()
}
// isEmptyRepo reports whether the checked-out branch has no tracked files
// other than the backend's default seed README.md. `git ls-files` listing
// nothing — or only README.md — counts as empty (→ scaffold via `app init`).
func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
stdout, stderr, err := initRunner.Run(ctx, dir, "git", "ls-files")
if err != nil {
return false, output.Errorf(output.ExitAPI, "git_ls_files", "git ls-files failed: %s", gitErr(stderr, err))
}
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
f := strings.TrimSpace(line)
// Match the seed exactly (case- and path-sensitive): only a root-level
// "README.md" is the backend's default seed. A docs/README.md or readme.md
// is treated as real content (→ non-empty), which is the safe direction
// (skip scaffolding rather than risk overwriting). Extend this allow-list
// here if the backend's seed set grows.
if f == "" || f == seedReadme {
continue
}
return false, nil // a non-README tracked file → non-empty repo
}
return true, nil
}
// runScaffold runs the npx scaffolding step inside the cloned repo (cwd=dir).
// Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch +
// conditional `skills sync`. Returns "init" or "upgrade".
func runScaffold(ctx context.Context, dir, appID, template string) (string, error) {
empty, err := isEmptyRepo(ctx, dir)
if err != nil {
return "", err
}
if empty {
// isEmptyRepo treats a repo with no tracked files — or only the backend's
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
// appear, extend isEmptyRepo's allow-list accordingly.
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
return "", output.Errorf(output.ExitAPI, "npx_app_init", "npx app init failed: %s", gitErr(stderr, err))
}
return scaffoldKindInit, nil
}
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil {
return "", output.Errorf(output.ExitAPI, "npx_app_sync", "npx app sync failed: %s", gitErr(stderr, err))
}
if err := ensureMetaAppID(dir, appID); err != nil {
return "", err
}
if !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
return "", output.Errorf(output.ExitAPI, "npx_skills_sync", "npx skills sync failed: %s", gitErr(stderr, err))
}
}
return scaffoldKindUpgrade, nil
}
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
// matches the contract emitted by `apps +git-credential-init`.
func parseRepoURLFromEnvelope(stdout string) (string, error) {
var env struct {
OK bool `json:"ok"`
Data struct {
RepositoryURL string `json:"repository_url"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
return "", output.Errorf(output.ExitInternal, "credential_init", "could not parse +git-credential-init output as JSON: %v", err)
}
if !env.OK {
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init reported failure")
}
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init returned no repository_url")
}
return env.Data.RepositoryURL, nil
}
// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success
// envelope ({"ok":true,"data":{"env_file":"..."}}) on stdout.
func parseEnvFileFromEnvelope(stdout string) (string, error) {
var env struct {
OK bool `json:"ok"`
Data struct {
EnvFile string `json:"env_file"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
return "", output.Errorf(output.ExitInternal, "env_pull", "could not parse +env-pull output as JSON: %v", err)
}
if !env.OK {
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull reported failure")
}
if strings.TrimSpace(env.Data.EnvFile) == "" {
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull returned no env_file")
}
return env.Data.EnvFile, nil
}
// parseEnvPullErrorEnvelope extracts a single-line reason from a `+env-pull`
// error envelope ({"ok":false,"error":{"type":...,"message":...}}) on stderr.
// Returns "" when stderr is not a parseable error envelope (caller falls back).
func parseEnvPullErrorEnvelope(stderr string) string {
var env struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(stderr)), &env); err != nil {
return ""
}
msg := strings.TrimSpace(env.Error.Message)
if msg == "" {
return ""
}
if t := strings.TrimSpace(env.Error.Type); t != "" {
return t + ": " + msg
}
return msg
}
// validateRepoURLScheme rejects any repository_url that is not http(s):// to
// block git's dangerous transports (ext::, file://, ssh://) and option injection.
func validateRepoURLScheme(repoURL string) error {
if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") {
return nil
}
return output.Errorf(output.ExitValidation, "validation",
"repository_url from +git-credential-init must be http(s); refusing %q", redactURLCredentials(repoURL))
}
func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
dir, err := resolveTargetPath(rctx, appID)
if err != nil {
return err
}
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
// initialized Miaoda app repo -> skip clone/scaffold/commit, but still refresh
// the local env so a re-run picks up the latest startup env vars.
if isAlreadyInitialized(dir) {
initLogf(rctx, "Already initialized at %s — refreshing local environment", dir)
out := map[string]interface{}{
"app_id": appID,
"clone_path": dir,
"scaffold": "already_initialized",
"committed": false,
"pushed": false,
}
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
out["env_pulled"] = envPulled
if envPulled {
initLogf(rctx, "Local environment written to %s", envFile)
out["env_file"] = envFile
out["message"] = "Repository already initialized. Local env refreshed — you can start developing."
} else {
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
out["env_pull_error"] = envPullErr
out["message"] = fmt.Sprintf("Repository already initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Already initialized at %s\n", dir)
if envPulled {
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
} else {
fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr)
fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID)
}
fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。")
})
return nil
}
if _, err := exec.LookPath("git"); err != nil {
return output.ErrWithHint(output.ExitInternal, "dependency",
"git executable not found on PATH", "install git and ensure it is on your PATH")
}
if _, err := exec.LookPath("npx"); err != nil {
return output.ErrWithHint(output.ExitInternal, "dependency",
"npx executable not found on PATH", "install Node.js (which provides npx) and ensure it is on your PATH")
}
if err := ensureEmptyDir(dir); err != nil {
return err
}
initLogf(rctx, "Issuing repository credentials for %s...", appID)
repoURL, err := issueCredentials(ctx, rctx, appID)
if err != nil {
return err
}
if err := validateRepoURLScheme(repoURL); err != nil {
return err
}
initLogf(rctx, "Cloning into %s...", dir)
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
return output.Errorf(output.ExitAPI, "git_clone", "git clone failed: %s", gitErr(stderr, err))
}
initLogf(rctx, "Checking out %s...", defaultInitBranch)
if _, stderr, err := initRunner.Run(ctx, dir, "git", "checkout", defaultInitBranch); err != nil {
return output.Errorf(output.ExitAPI, "git_checkout", "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
}
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
if err != nil {
return err
}
committed, pushed, err := commitAndPushIfDirty(ctx, dir, scaffold)
if err != nil {
return err
}
if pushed {
initLogf(rctx, "Committed and pushed to %s", defaultInitBranch)
} else {
initLogf(rctx, "Working tree clean — skipped commit/push")
}
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
if envPulled {
initLogf(rctx, "Local environment written to %s", envFile)
} else {
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
}
out := map[string]interface{}{
"app_id": appID,
"repository_url": redactURLCredentials(repoURL),
"branch": defaultInitBranch,
"clone_path": dir,
"scaffold": scaffold,
"committed": committed,
"pushed": pushed,
"env_pulled": envPulled,
"message": "Repository initialized. You can start developing.",
}
if envPulled {
out["env_file"] = envFile
} else {
out["env_pull_error"] = envPullErr
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir)
fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold)
if envPulled {
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
} else {
fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr)
fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID)
}
fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。")
})
return nil
}
// pullEnv runs `<self> apps +env-pull --app-id <appID> --project-path <dir>
// --format json`, forwarding --as when set. Returns (envFile, "") on success or
// ("", reason) on failure. Non-fatal by contract: the caller logs a warning and
// continues. The success envelope is read from stdout, the error envelope from
// stderr (lark-cli writes structured errors to stderr; see cmd/root.go
// handleRootError). The reason is always redacted.
func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string) (envFile, reason string) {
self, err := os.Executable()
if err != nil {
return "", redactURLCredentials(fmt.Sprintf("cannot locate lark-cli executable: %v", err))
}
args := []string{"apps", "+env-pull", "--app-id", appID, "--project-path", dir, "--format", "json"}
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
args = append(args, "--as", as)
}
stdout, stderr, runErr := initRunner.Run(ctx, "", self, args...)
if runErr != nil {
r := parseEnvPullErrorEnvelope(stderr)
if r == "" {
r = gitErr(stderr, runErr)
}
return "", redactURLCredentials(r)
}
envFile, perr := parseEnvFileFromEnvelope(stdout)
if perr != nil {
return "", redactURLCredentials(perr.Error())
}
return envFile, ""
}
// issueCredentials runs `<self> apps +git-credential-init --app-id <id> --format json`
// and returns the repo_url it reports. Forwards --as when set.
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
self, err := os.Executable()
if err != nil {
return "", output.Errorf(output.ExitInternal, "internal", "cannot locate lark-cli executable: %v", err)
}
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
args = append(args, "--as", as)
}
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
if err != nil {
return "", output.ErrWithHint(output.ExitAPI, "credential_init",
fmt.Sprintf("apps +git-credential-init failed: %s", gitErr(stderr, err)),
"ensure apps +git-credential-init is available and you are logged in")
}
return parseRepoURLFromEnvelope(stdout)
}
// commitAndPushIfDirty commits and pushes only when the working tree has
// changes; a clean tree is a no-op (returns false,false). For the empty-repo
// init path (scaffoldKind == "init") it splits the scaffolded tree into two
// commits — app project code, then Miaoda config (.spark/.agent) — skipping
// either commit when that group has no changes (no empty commits). Other paths
// commit once. Push is a single `git push origin <branch>` for all commits.
func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) {
status, stderr, runErr := initRunner.Run(ctx, dir, "git", "status", "--porcelain")
if runErr != nil {
return false, false, output.Errorf(output.ExitAPI, "git_status", "git status failed: %s", gitErr(stderr, runErr))
}
if strings.TrimSpace(status) == "" {
return false, false, nil
}
if scaffoldKind == scaffoldKindInit {
// Stage each group by its exact porcelain paths (never gitignored files),
// so neither `git add` errors on an ignored path like .agent.
appPaths, configPaths := classifyPorcelain(status)
if len(appPaths) > 0 {
if e := stageAndCommit(ctx, dir, commitMsgAppCode, appPaths...); e != nil {
return committed, false, e
}
committed = true
}
if len(configPaths) > 0 {
if e := stageAndCommit(ctx, dir, commitMsgAppConfig, configPaths...); e != nil {
return committed, false, e
}
committed = true
}
} else {
if e := stageAndCommit(ctx, dir, commitMsgUpgrade, "."); e != nil {
return false, false, e
}
committed = true
}
if !committed {
return false, false, nil
}
if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil {
return true, false, withAppsHint(
output.Errorf(output.ExitAPI, "git_push", "git push failed: %s", gitErr(se, e)),
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded")
}
return true, true, nil
}
// stageAndCommit stages the given pathspecs (`git add -A -- <pathspecs>`) and
// makes one `git commit --no-verify -m message`. --no-verify skips the scaffold
// repo's local pre-commit / commit-msg hooks (local only; the later push is not
// --no-verify). Callers gate this on classifyPorcelain so the group is non-empty
// and the commit never hits "nothing to commit".
func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...string) error {
addArgs := append([]string{"add", "-A", "--"}, pathspecs...)
if _, se, e := initRunner.Run(ctx, dir, "git", addArgs...); e != nil {
return output.Errorf(output.ExitAPI, "git_add", "git add failed: %s", gitErr(se, e))
}
if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil {
return output.Errorf(output.ExitAPI, "git_commit", "git commit failed: %s", gitErr(se, e))
}
return nil
}
// classifyPorcelain parses `git status --porcelain` output and partitions the
// changed paths into the "app code" group (anything outside .spark/ and .agent/)
// and the "Miaoda config" group (.spark/ and .agent/). It returns the exact
// porcelain paths so callers can stage them verbatim: porcelain never lists
// gitignored files, so `git add -- <these paths>` never trips git's ignored-path
// error. (Naming an ignored dir explicitly — or combining a "." pathspec with
// :(exclude) magic — DOES error when a scaffold template gitignores e.g. .agent,
// which is why we stage exact paths instead of pathspecs.)
func classifyPorcelain(status string) (appPaths, configPaths []string) {
for _, line := range strings.Split(status, "\n") {
p := porcelainPath(line)
if p == "" {
continue
}
if isConfigPath(p) {
configPaths = append(configPaths, p)
} else {
appPaths = append(appPaths, p)
}
}
return appPaths, configPaths
}
// porcelainPath extracts the path from a `git status --porcelain` v1 line.
// Format is "XY <path>" (2 status chars + space); rename/copy lines are
// "XY <orig> -> <dest>" (dest is what matters). Quoted paths are unquoted.
func porcelainPath(line string) string {
if len(line) < 4 {
return ""
}
p := line[3:]
if i := strings.Index(p, " -> "); i >= 0 {
p = p[i+len(" -> "):]
}
p = strings.TrimSpace(p)
p = strings.Trim(p, `"`)
return p
}
// isConfigPath reports whether p is the Miaoda app-config group: the .spark or
// .agent directory itself, or anything under them. ".sparkrc" is NOT config.
func isConfigPath(p string) bool {
return p == ".spark" || p == ".agent" ||
strings.HasPrefix(p, ".spark/") || strings.HasPrefix(p, ".agent/")
}
// gitErr builds a redacted, single-line error detail from stderr (falling back
// to the exec error). Always redacts embedded credentials.
func gitErr(stderr string, err error) string {
s := strings.TrimSpace(stderr)
if s == "" && err != nil {
s = err.Error()
}
return redactURLCredentials(s)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
)
func TestListyCommandsHaveJqTip(t *testing.T) {
wantCmds := map[string]bool{
"+list": true, "+db-table-list": true, "+db-table-schema": true,
"+db-sql": true, "+release-list": true, "+session-list": true,
}
for _, s := range Shortcuts() {
if !wantCmds[s.Command] {
continue
}
has := false
for _, tip := range s.Tips {
if strings.Contains(tip, "--jq") || strings.Contains(tip, "-q '") {
has = true
}
}
if !has {
t.Errorf("%s should have a --jq filter tip", s.Command)
}
}
}

View File

@@ -12,30 +12,23 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsList lists Miaoda apps visible to the calling user (cursor pagination).
// AppsList lists Miaoda apps owned by the calling user (cursor pagination).
//
// Supports name fuzzy match (--keyword), ownership-dimension filter
// (--ownership: all / mine / shared), and app-type filter (--app-type). See
// lark-apps SKILL.md for when an agent should use this to resolve an app_id
// from a user-supplied name (only when the user named an app and a downstream
// op needs its app_id — never unconditional enumeration).
// Hidden from --help / tab completion (Hidden: true) so agents do not discover it
// as a way to enumerate / search applications. Direct invocation still works for
// humans who know the command. When agents need an existing app_id, they should
// ask the user to provide either the Miaoda app URL (extract app_id from the
// path segment after /app/) or the app_id string directly; see lark-apps SKILL.md.
var AppsList = common.Shortcut{
Service: appsService,
Command: "+list",
Description: "List Miaoda apps visible to the calling user (cursor pagination)",
Description: "List Miaoda apps owned by the calling user (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +list",
"Example: lark-cli apps +list --keyword <keyword>",
"Tip: filter fields with --jq, e.g. -q '.data.items[].app_id'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Hidden: true,
Flags: []common.Flag{
{Name: "keyword", Desc: "fuzzy match on app name"},
{Name: "ownership", Desc: "ownership filter: all (created by me + shared with me) | mine | shared", Enum: []string{"all", "mine", "shared"}},
{Name: "app-type", Desc: "app type filter (html or full_stack)", Enum: []string{"html", "full_stack"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
@@ -46,42 +39,18 @@ var AppsList = common.Shortcut{
Params(buildAppsListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
data, err := rctx.CallAPI("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
if err != nil {
return err
}
// Project away icon_url (an image URL agents can't render) and created_at
// (redundant with updated_at) from every item BEFORE OutFormat, so json /
// table / pretty are all lean. Every other field (description, etc.) is kept.
rawItems, _ := data["items"].([]interface{})
items := make([]interface{}, 0, len(rawItems))
for _, item := range rawItems {
m, ok := item.(map[string]interface{})
if !ok {
items = append(items, item)
continue
}
out := make(map[string]interface{}, len(m))
for k, v := range m {
if k == "icon_url" || k == "created_at" {
continue
}
out[k] = v
}
items = append(items, out)
}
data["items"] = items
items, _ := data["items"].([]interface{})
rctx.OutFormat(data, nil, func(w io.Writer) {
// Curated pretty view (--format pretty) shows the columns most useful
// for visual scanning: app_id (to copy-paste downstream), name (to match
// what the user sees in the UI), is_published / online_url (publish state
// and post-publish access link — the actionable fields after a deploy),
// and updated_at (to pick the most recent variant). online_url can be long
// but is the key value once published; the renderer clamps column width.
// Unpublished apps carry no online_url, so that cell renders empty.
// description stays in the underlying data (--format json / table) but
// would make the curated view too wide. icon_url / created_at are trimmed
// from the data entirely above (not useful to an agent).
// Table view (--format table) intentionally shows only the columns
// most useful for visual scanning: app_id (to copy-paste downstream),
// name (to match what the user sees in the UI), and updated_at (to
// pick the most recent variant). description / icon_url / created_at
// stay in the underlying JSON (--format json) but would make the
// table too wide for a terminal.
rows := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]interface{})
@@ -89,11 +58,9 @@ var AppsList = common.Shortcut{
continue
}
rows = append(rows, map[string]interface{}{
"app_id": m["app_id"],
"name": m["name"],
"is_published": m["is_published"],
"online_url": m["online_url"],
"updated_at": m["updated_at"],
"app_id": m["app_id"],
"name": m["name"],
"updated_at": m["updated_at"],
})
}
output.PrintTable(w, rows)
@@ -109,14 +76,5 @@ func buildAppsListParams(rctx *common.RuntimeContext) map[string]interface{} {
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
params["page_token"] = token
}
if kw := strings.TrimSpace(rctx.Str("keyword")); kw != "" {
params["keyword"] = kw
}
if ownership := strings.TrimSpace(rctx.Str("ownership")); ownership != "" {
params["ownership"] = ownership
}
if at := strings.TrimSpace(rctx.Str("app-type")); at != "" {
params["app_type"] = at
}
return params
}

View File

@@ -63,56 +63,6 @@ func TestAppsList_WithPageToken(t *testing.T) {
}
}
func TestAppsList_WithKeywordOwnershipAppType(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps?app_type=html&keyword=%E9%97%AE%E5%8D%B7&ownership=mine&page_size=20",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
},
})
if err := runAppsShortcut(t, AppsList,
[]string{"+list", "--keyword", "问卷", "--ownership", "mine", "--app-type", "html", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}
func TestAppsList_InvalidOwnership(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsList,
[]string{"+list", "--ownership", "bogus", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected enum validation error for --ownership bogus")
}
}
func TestAppsList_InvalidAppType(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsList,
[]string{"+list", "--app-type", "HTML", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected enum validation error for --app-type HTML (hard cut to lowercase)")
}
}
func TestAppsList_DryRunWithFilters(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsList,
[]string{"+list", "--keyword", "q", "--ownership", "all", "--app-type", "full_stack", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
for _, want := range []string{"keyword", "ownership", "app_type", "full_stack"} {
if !strings.Contains(got, want) {
t.Fatalf("dry-run missing %q: %s", want, got)
}
}
}
func TestAppsList_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsList,
@@ -128,86 +78,3 @@ func TestAppsList_DryRun(t *testing.T) {
t.Fatalf("dry-run missing page_size param: %s", got)
}
}
func TestAppsList_TrimsIconURLAndCreatedAt(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps?page_size=20",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"app_id": "app_x",
"name": "Trim Me",
"is_published": true,
"online_url": "https://example.com/spark/faas/app_x",
"updated_at": "2026-05-28T10:05:16Z",
"created_at": "2026-05-01T08:00:00Z",
"icon_url": "https://example.com/icon.png",
"description": "An app to test trimming",
},
},
"page_token": "next_cursor",
"has_more": true,
},
},
})
if err := runAppsShortcut(t, AppsList, []string{"+list", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, drop := range []string{"icon_url", "created_at"} {
if strings.Contains(got, drop) {
t.Fatalf("default output should not contain %q:\n%s", drop, got)
}
}
for _, keep := range []string{"app_id", "name", "is_published", "online_url", "updated_at", "description"} {
if !strings.Contains(got, keep) {
t.Fatalf("default output missing %q:\n%s", keep, got)
}
}
}
func TestAppsList_PrettyShowsPublishFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps?page_size=20",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"app_id": "app_pub",
"name": "Published App",
"is_published": true,
"online_url": "https://example.com/spark/faas/app_pub",
"updated_at": "2026-05-28T10:05:16Z",
},
map[string]interface{}{
"app_id": "app_draft",
"name": "Draft App",
"is_published": false,
"updated_at": "2026-05-31T12:31:27Z",
},
},
"has_more": false,
},
},
})
if err := runAppsShortcut(t, AppsList,
[]string{"+list", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{"is_published", "online_url", "https://example.com/spark/faas/app_pub", "true", "false"} {
if !strings.Contains(got, want) {
t.Fatalf("pretty output missing %q:\n%s", want, got)
}
}
}

View File

@@ -1,40 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"io"
"github.com/larksuite/cli/internal/output"
)
// Gateway paths for the spark app.release OpenAPI methods.
// Prefix reuses apiBasePath = "/open-apis/spark/v1" (same package).
// Each path contains %s placeholders; use fmt.Sprintf to build the final URL.
const (
releaseCreatePath = apiBasePath + "/apps/%s/releases"
releaseGetPath = apiBasePath + "/apps/%s/releases/%s"
releaseListPath = apiBasePath + "/apps/%s/releases"
)
// writeReleaseErrorLogTable renders a release's error_logs (a slice of
// {step, error_log} maps from the gateway) as a two-column step/error_log
// table via output.PrintTable. Used by +release-get to render a failed
// release's error_logs. A nil/non-slice or
// empty value yields an empty table (PrintTable prints "(no data)").
func writeReleaseErrorLogTable(w io.Writer, raw interface{}) {
logs, _ := raw.([]interface{})
rows := make([]map[string]interface{}, 0, len(logs))
for _, l := range logs {
m, ok := l.(map[string]interface{})
if !ok {
continue
}
rows = append(rows, map[string]interface{}{
"step": m["step"],
"error_log": m["error_log"],
})
}
output.PrintTable(w, rows)
}

View File

@@ -1,76 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsReleaseCreate creates a release for a Miaoda app.
var AppsReleaseCreate = common.Shortcut{
Service: appsService,
Command: "+release-create",
Description: "Create a release for a Miaoda app (returns release_id for status polling)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +release-create --app-id <app_id>",
"Example: lark-cli apps +release-create --app-id <app_id> --branch sprint/default --dry-run",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
branch := strings.TrimSpace(rctx.Str("branch"))
dry := common.NewDryRunAPI()
dry.POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))).
Desc("Create a release").
Body(buildPublishBody(branch))
return dry
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
branch := strings.TrimSpace(rctx.Str("branch"))
path := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("POST", path, nil, buildPublishBody(branch))
if err != nil {
return withAppsHint(err, "if the push was rejected (non-fast-forward), sync first with `git pull --rebase origin sprint/default` then retry; inspect the failure via `lark-cli apps +release-get --app-id "+appID+" --release-id <release_id>`")
}
out := map[string]interface{}{
"release_id": common.GetString(data, "release_id"),
"status": common.GetString(data, "status"),
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "release_id: %s\nstatus: %s\n", out["release_id"], out["status"])
})
return nil
},
}
// buildPublishBody builds the create-release request body. app_id is in the
// path, not the body. branch is included only when non-empty.
func buildPublishBody(branch string) map[string]interface{} {
body := map[string]interface{}{}
if branch != "" {
body["branch"] = branch
}
return body
}

View File

@@ -1,107 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestBuildPublishBody(t *testing.T) {
// branch included when non-empty; app_id is NOT in body (it's in the path)
b := buildPublishBody("feat/devops")
if b["branch"] != "feat/devops" {
t.Errorf("body = %v", b)
}
if _, ok := b["app_id"]; ok {
t.Errorf("app_id must not be in body, got %v", b)
}
// branch omitted when empty
b2 := buildPublishBody("")
if _, ok := b2["branch"]; ok {
t.Errorf("branch should be omitted when empty, got %v", b2)
}
}
func TestAppsReleaseCreateMeta(t *testing.T) {
if AppsReleaseCreate.Command != "+release-create" || AppsReleaseCreate.Risk != "write" {
t.Errorf("meta mismatch: %+v", AppsReleaseCreate)
}
if len(AppsReleaseCreate.Scopes) != 1 || AppsReleaseCreate.Scopes[0] != "spark:app:write" {
t.Errorf("scopes = %v", AppsReleaseCreate.Scopes)
}
}
// newReleaseCreateRuntimeContext builds a RuntimeContext whose cobra.Command has the
// flags that AppsReleaseCreate.Execute reads (app-id, branch). Flag values are set
// via the returned setter helper.
func newReleaseCreateRuntimeContext(t *testing.T, appID, branch string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "test-release-create"}
cmd.SetContext(context.Background())
cmd.Flags().String("app-id", "", "")
cmd.Flags().String("branch", "", "")
_ = cmd.Flags().Set("app-id", appID)
if branch != "" {
_ = cmd.Flags().Set("branch", branch)
}
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
return rctx, stdoutBuf, reg
}
func TestAppsReleaseCreateExecute_Success(t *testing.T) {
rctx, stdoutBuf, reg := newReleaseCreateRuntimeContext(t, "app_x", "main")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/releases",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"release_id": "123",
"status": "publishing",
},
},
})
err := AppsReleaseCreate.Execute(context.Background(), rctx)
if err != nil {
t.Fatalf("Execute() = %v", err)
}
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
}
if !env.OK {
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
}
if env.Data["release_id"] != "123" {
t.Errorf("release_id = %v, want 123", env.Data["release_id"])
}
if env.Data["status"] != "publishing" {
t.Errorf("status = %v, want publishing", env.Data["status"])
}
}

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsReleaseGet fetches a single release's detail by release ID.
var AppsReleaseGet = common.Shortcut{
Service: appsService,
Command: "+release-get",
Description: "Get a single release's status/detail by release ID",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +release-get --app-id <app_id> --release-id <release_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
if strings.TrimSpace(rctx.Str("release-id")) == "" {
return output.ErrValidation("--release-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
releaseID := strings.TrimSpace(rctx.Str("release-id"))
dry := common.NewDryRunAPI()
dry.GET(fmt.Sprintf(releaseGetPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(releaseID))).
Desc("Get release detail")
return dry
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
releaseID := strings.TrimSpace(rctx.Str("release-id"))
path := fmt.Sprintf(releaseGetPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(releaseID))
data, err := rctx.CallAPITyped("GET", path, nil, nil)
if err != nil {
return withAppsHint(err, "if the release_id is unknown or invalid, list this app's releases with `lark-cli apps +release-list --app-id "+appID+"`")
}
out := data
if release, ok := data["release"].(map[string]interface{}); ok {
out = release
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "release_id: %v\nstatus: %v\ncreated_at: %v\nupdated_at: %v\n",
out["release_id"], out["status"], out["created_at"], out["updated_at"])
if commitID, ok := out["commit_id"].(string); ok && commitID != "" {
fmt.Fprintf(w, "commit_id: %s\n", commitID)
}
status, _ := out["status"].(string)
switch status {
case "finished":
if url, ok := out["online_url"].(string); ok && url != "" {
fmt.Fprintf(w, "online_url: %s\n", url)
}
case "failed":
writeReleaseErrorLogTable(w, out["error_logs"])
}
})
return nil
},
}

View File

@@ -1,300 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestAppsReleaseGetMeta(t *testing.T) {
if AppsReleaseGet.Command != "+release-get" || AppsReleaseGet.Risk != "read" {
t.Errorf("meta mismatch: %+v", AppsReleaseGet)
}
if len(AppsReleaseGet.Scopes) != 1 || AppsReleaseGet.Scopes[0] != "spark:app:read" {
t.Errorf("scopes = %v", AppsReleaseGet.Scopes)
}
// both --app-id and --release-id must be required
req := map[string]bool{}
for _, f := range AppsReleaseGet.Flags {
req[f.Name] = f.Required
}
if !req["app-id"] || !req["release-id"] {
t.Errorf("app-id and release-id must be Required; flags=%+v", AppsReleaseGet.Flags)
}
}
// newStatusRuntimeContext builds a RuntimeContext for AppsReleaseGet.Execute tests.
func newStatusRuntimeContext(t *testing.T, appID, releaseID string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "test-release-get"}
cmd.SetContext(context.Background())
cmd.Flags().String("app-id", "", "")
cmd.Flags().String("release-id", "", "")
_ = cmd.Flags().Set("app-id", appID)
_ = cmd.Flags().Set("release-id", releaseID)
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
return rctx, stdoutBuf, reg
}
func TestAppsReleaseGetExecute_Success(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/releases/5",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"release": map[string]interface{}{
"release_id": "5",
"status": "finished",
"created_at": "1700000000000",
"updated_at": "1700000000001",
},
},
},
})
err := AppsReleaseGet.Execute(context.Background(), rctx)
if err != nil {
t.Fatalf("Execute() = %v", err)
}
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
}
if !env.OK {
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
}
// Execute unwraps the nested "release" object
if env.Data["release_id"] != "5" {
t.Errorf("release_id = %v, want 5", env.Data["release_id"])
}
if env.Data["status"] != "finished" {
t.Errorf("status = %v, want finished", env.Data["status"])
}
}
func TestAppsReleaseGetPrettyFinishedOnlineURL(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
rctx.Format = "pretty"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/releases/5",
Body: map[string]interface{}{
"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "5", "status": "finished",
"created_at": "1700000000000", "updated_at": "1700000000001",
"online_url": "https://example.feishu.cn/spark/faas/app_x",
}},
},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
if !strings.Contains(out, "status: finished") {
t.Errorf("missing base fields:\n%s", out)
}
if !strings.Contains(out, "online_url: https://example.feishu.cn/spark/faas/app_x") {
t.Errorf("expected online_url line, got:\n%s", out)
}
}
func TestAppsReleaseGetPrettyFailedErrorLogs(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "6")
rctx.Format = "pretty"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/releases/6",
Body: map[string]interface{}{
"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "6", "status": "failed",
"created_at": "1700000000000", "updated_at": "1700000000050",
"error_logs": []interface{}{
map[string]interface{}{"step": "build", "error_log": "compile error"},
},
}},
},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
if !strings.Contains(out, "status: failed") {
t.Errorf("missing base fields:\n%s", out)
}
if !strings.Contains(out, "build") || !strings.Contains(out, "compile error") {
t.Errorf("expected error_logs table with step/error_log, got:\n%s", out)
}
}
func TestAppsReleaseGetPrettyPublishingNoExtra(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "7")
rctx.Format = "pretty"
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/7",
Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "7", "status": "publishing",
"created_at": "1700000000000", "updated_at": "1700000000000",
}}},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
if strings.Contains(out, "online_url:") || strings.Contains(out, "error_log") {
t.Errorf("publishing must not add extra fields, got:\n%s", out)
}
}
func TestAppsReleaseGetPrettyFinishedNoURL(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "8")
rctx.Format = "pretty"
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/8",
Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "8", "status": "finished",
"created_at": "1700000000000", "updated_at": "1700000000001",
}}},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if strings.Contains(stdoutBuf.String(), "online_url:") {
t.Errorf("finished without online_url must not print the line, got:\n%s", stdoutBuf.String())
}
}
func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "9")
rctx.Format = "pretty"
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/9",
Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "9", "status": "failed",
"created_at": "1700000000000", "updated_at": "1700000000050",
"error_logs": []interface{}{},
}}},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if strings.Contains(stdoutBuf.String(), "compile error") {
t.Errorf("empty error_logs must not render row content, got:\n%s", stdoutBuf.String())
}
}
func TestAppsReleaseGetPrettyCommitID(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "10")
rctx.Format = "pretty"
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/10",
Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "10", "status": "publishing",
"created_at": "1700000000000", "updated_at": "1700000000000",
"commit_id": "1230aisdkjah9123913hi193",
}}},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if !strings.Contains(stdoutBuf.String(), "commit_id: 1230aisdkjah9123913hi193") {
t.Errorf("expected commit_id line, got:\n%s", stdoutBuf.String())
}
}
func TestAppsReleaseGetPrettyNoCommitID(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "11")
rctx.Format = "pretty"
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/11",
Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "11", "status": "publishing",
"created_at": "1700000000000", "updated_at": "1700000000000",
}}},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if strings.Contains(stdoutBuf.String(), "commit_id:") {
t.Errorf("absent commit_id must not print commit_id line, got:\n%s", stdoutBuf.String())
}
}
func TestAppsReleaseGetPrettyEmptyCommitID(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "12")
rctx.Format = "pretty"
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/12",
Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "12", "status": "publishing",
"created_at": "1700000000000", "updated_at": "1700000000000",
"commit_id": "",
}}},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if strings.Contains(stdoutBuf.String(), "commit_id:") {
t.Errorf("empty commit_id must not print commit_id line, got:\n%s", stdoutBuf.String())
}
}
func TestAppsReleaseGetJSONOnlineURLPassthrough(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/5",
Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "5", "status": "finished",
"created_at": "1700000000000", "updated_at": "1700000000001",
"online_url": "https://example.feishu.cn/spark/faas/app_x",
}}},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
}
if env.Data["online_url"] != "https://example.feishu.cn/spark/faas/app_x" {
t.Errorf("JSON must passthrough online_url, got: %v", env.Data["online_url"])
}
}

View File

@@ -1,98 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsReleaseList lists a Miaoda app's release history (most recent first).
var AppsReleaseList = common.Shortcut{
Service: appsService,
Command: "+release-list",
Description: "List a Miaoda app's release history (most recent first)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +release-list --app-id <app_id>",
"Tip: filter fields with --jq, e.g. -q '.data.releases[].release_id'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "status", Enum: []string{"publishing", "finished", "failed"}, Desc: "filter by release status: publishing | finished | failed"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (max 500)"},
{Name: "page-token", Desc: "pagination cursor from a previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
status := strings.TrimSpace(rctx.Str("status"))
pageSize := rctx.Int("page-size")
pageToken := strings.TrimSpace(rctx.Str("page-token"))
dry := common.NewDryRunAPI()
dry.GET(fmt.Sprintf(releaseListPath, validate.EncodePathSegment(appID))).
Desc("List release history").
Params(buildReleaseListQuery(status, pageSize, pageToken))
return dry
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
status := strings.TrimSpace(rctx.Str("status"))
pageSize := rctx.Int("page-size")
pageToken := strings.TrimSpace(rctx.Str("page-token"))
path := fmt.Sprintf(releaseListPath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("GET", path, buildReleaseListQuery(status, pageSize, pageToken), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
releases, _ := data["releases"].([]interface{})
rctx.OutFormat(data, nil, func(w io.Writer) {
rows := make([]map[string]interface{}, 0, len(releases))
for _, it := range releases {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
rows = append(rows, map[string]interface{}{
"release_id": m["release_id"],
"status": m["status"],
"created_at": m["created_at"],
"updated_at": m["updated_at"],
})
}
output.PrintTable(w, rows)
})
return nil
},
}
// buildReleaseListQuery builds the list-releases query parameters. app_id is in
// the path. page_size is always sent; status and page_token (snake) are included
// only when non-empty.
func buildReleaseListQuery(status string, pageSize int, pageToken string) map[string]interface{} {
q := map[string]interface{}{
"page_size": pageSize,
}
if status != "" {
q["status"] = status
}
if pageToken != "" {
q["page_token"] = pageToken
}
return q
}

View File

@@ -1,129 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestBuildReleaseListQuery(t *testing.T) {
// page_size always present; status/page_token omitted when empty; app_id is in the path
q := buildReleaseListQuery("", 0, "")
if q["page_size"] != 0 {
t.Errorf("page_size should always be present, got %v", q)
}
if _, ok := q["status"]; ok {
t.Errorf("status should be omitted when empty, got %v", q)
}
if _, ok := q["page_token"]; ok {
t.Errorf("page_token should be omitted when empty, got %v", q)
}
q2 := buildReleaseListQuery("finished", 30, "tok")
if q2["page_size"] != 30 {
t.Errorf("page_size = %v, want 30", q2["page_size"])
}
if q2["status"] != "finished" {
t.Errorf("status = %v, want finished", q2["status"])
}
if q2["page_token"] != "tok" {
t.Errorf("page_token = %v, want tok", q2["page_token"])
}
if _, ok := q2["app_id"]; ok {
t.Errorf("app_id must not be in query params, got %v", q2)
}
}
// newReleaseListRuntimeContext builds a RuntimeContext for AppsReleaseList.Execute tests.
func newReleaseListRuntimeContext(t *testing.T, appID string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "test-release-list"}
cmd.SetContext(context.Background())
cmd.Flags().String("app-id", "", "")
cmd.Flags().String("status", "", "")
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().String("page-token", "", "")
_ = cmd.Flags().Set("app-id", appID)
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
return rctx, stdoutBuf, reg
}
func TestAppsReleaseListExecute_Success(t *testing.T) {
rctx, stdoutBuf, reg := newReleaseListRuntimeContext(t, "app_x")
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/releases",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"releases": []interface{}{
map[string]interface{}{
"release_id": "1",
"status": "finished",
"created_at": "1700000000000",
"updated_at": "1700000000000",
},
},
"next_page_token": "tok",
"has_more": true,
},
},
})
err := AppsReleaseList.Execute(context.Background(), rctx)
if err != nil {
t.Fatalf("Execute() = %v", err)
}
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
}
if !env.OK {
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
}
// releases passthrough
releases, ok := env.Data["releases"].([]interface{})
if !ok || len(releases) != 1 {
t.Fatalf("releases = %v", env.Data["releases"])
}
r0 := releases[0].(map[string]interface{})
if r0["release_id"] != "1" {
t.Errorf("releases[0].release_id = %v, want 1", r0["release_id"])
}
if r0["status"] != "finished" {
t.Errorf("releases[0].status = %v, want finished", r0["status"])
}
// pagination fields passthrough
if env.Data["next_page_token"] != "tok" {
t.Errorf("next_page_token = %v, want tok", env.Data["next_page_token"])
}
if env.Data["has_more"] != true {
t.Errorf("has_more = %v, want true", env.Data["has_more"])
}
}

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsSessionCreate creates a new session under an existing Miaoda app.
var AppsSessionCreate = common.Shortcut{
Service: appsService,
Command: "+session-create",
Description: "Create a session under a Miaoda app",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +session-create --app-id <app_id>",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(sessionsPath(rctx.Str("app-id"))).
Desc("Create a session under a Miaoda app")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("POST", sessionsPath(rctx.Str("app-id")), nil, nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "session created: %s\n", common.GetString(data, "session_id"))
})
return nil
},
}
// sessionsPath builds the collection path for an app's sessions.
func sessionsPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/sessions", apiBasePath, validate.EncodePathSegment(strings.TrimSpace(appID)))
}

View File

@@ -1,85 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsSessionCreate_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sessions",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"session_id": "conv_new"},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsSessionCreate,
[]string{"+session-create", "--app-id", "app_x", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"session_id": "conv_new"`) {
t.Fatalf("stdout missing session_id: %s", got)
}
if len(stub.CapturedBody) != 0 {
t.Fatalf("+session-create must POST with no body, got: %s", stub.CapturedBody)
}
}
func TestAppsSessionCreate_Pretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sessions",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"session_id": "conv_new"}},
})
if err := runAppsShortcut(t, AppsSessionCreate,
[]string{"+session-create", "--app-id", "app_x", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "session created: conv_new") {
t.Fatalf("pretty output wrong: %q", got)
}
}
func TestAppsSessionCreate_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
// present-but-blank --app-id passes cobra MarkFlagRequired, caught by Validate hook.
err := runAppsShortcut(t, AppsSessionCreate, []string{"+session-create", "--app-id", "", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected --app-id required error, got %v", err)
}
}
func TestAppsSessionCreate_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsSessionCreate,
[]string{"+session-create", "--app-id", "app_x", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
}
func TestAppsSessionCreate_EncodesAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsSessionCreate,
[]string{"+session-create", "--app-id", "a/b", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
if got := stdout.String(); strings.Contains(got, "apps/a/b/sessions") {
t.Fatalf("app_id must be path-encoded, got raw slash: %s", got)
}
}

View File

@@ -1,73 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsSessionGet reads a session's current status, queued turns, and latest turn.
// Single-shot: the caller drives polling using next_poll_after_ms.
var AppsSessionGet = common.Shortcut{
Service: appsService,
Command: "+session-get",
Description: "Read a session's current status, queued turns, and latest turn",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +session-get --app-id <app_id> --session-id <session_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "session-id", Desc: "session ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
if strings.TrimSpace(rctx.Str("session-id")) == "" {
return output.ErrValidation("--session-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(sessionPath(rctx.Str("app-id"), rctx.Str("session-id"))).
Desc("Read a session's status")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("GET", sessionPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, nil)
if err != nil {
return withAppsHint(err, "if the session_id is unknown or invalid, list this app's sessions with `lark-cli apps +session-list --app-id "+strings.TrimSpace(rctx.Str("app-id"))+"`")
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "session: %s\n", common.GetString(data, "session_id"))
fmt.Fprintf(w, "active: %v streaming: %v\n", data["is_active"], data["is_streaming"])
if lt, ok := data["latest_turn"].(map[string]interface{}); ok {
fmt.Fprintf(w, "latest turn: %v (%v)\n", lt["turn_id"], lt["status"])
}
fmt.Fprintf(w, "queued: %v\n", data["queued_count"])
fmt.Fprintf(w, "next poll after: %vms\n", data["next_poll_after_ms"])
})
return nil
},
}
// sessionPath builds the single-session path under an app. Defined here (first
// consumer) so it never sits unused. Reused by Task 4 (+session-stop) and Task 5 (+chat).
func sessionPath(appID, sessionID string) string {
return fmt.Sprintf("%s/apps/%s/sessions/%s",
apiBasePath,
validate.EncodePathSegment(strings.TrimSpace(appID)),
validate.EncodePathSegment(strings.TrimSpace(sessionID)))
}

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func sessionGetStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"session_id": "conv_x",
"is_active": true,
"is_streaming": true,
"summary": "正在补充...",
"queued_count": 1,
"latest_turn": map[string]interface{}{"turn_id": "8421374923", "status": "running"},
"next_poll_after_ms": 30000,
},
},
}
}
func TestAppsSessionGet_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(sessionGetStub())
if err := runAppsShortcut(t, AppsSessionGet,
[]string{"+session-get", "--app-id", "app_x", "--session-id", "conv_x", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"is_streaming": true`) {
t.Fatalf("stdout missing is_streaming: %s", got)
}
}
func TestAppsSessionGet_PrettyReadsNestedSnakeCase(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(sessionGetStub())
if err := runAppsShortcut(t, AppsSessionGet,
[]string{"+session-get", "--app-id", "app_x", "--session-id", "conv_x", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "8421374923") || !strings.Contains(got, "running") {
t.Fatalf("pretty must read latest_turn.turn_id/status: %s", got)
}
if !strings.Contains(got, "30000") {
t.Fatalf("pretty must show next_poll_after_ms: %s", got)
}
}
func TestAppsSessionGet_RequiresFlags(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsSessionGet, []string{"+session-get", "--app-id", "app_x", "--session-id", "", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "session-id") {
t.Fatalf("expected --session-id required error, got %v", err)
}
}
func TestAppsSessionGet_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsSessionGet,
[]string{"+session-get", "--app-id", "app_x", "--session-id", "conv_x", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
}

View File

@@ -1,79 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsSessionList lists sessions under a Miaoda app (cursor pagination, single page).
var AppsSessionList = common.Shortcut{
Service: appsService,
Command: "+session-list",
Description: "List sessions under a Miaoda app (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +session-list --app-id <app_id>",
"Tip: filter fields with --jq, e.g. -q '.data.sessions[].session_id'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (max 50)"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(sessionsPath(rctx.Str("app-id"))).
Desc("List sessions under a Miaoda app").
Params(buildSessionListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("GET", sessionsPath(rctx.Str("app-id")), buildSessionListParams(rctx), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
sessions, _ := data["sessions"].([]interface{})
rctx.OutFormat(data, nil, func(w io.Writer) {
rows := make([]map[string]interface{}, 0, len(sessions))
for _, item := range sessions {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
rows = append(rows, map[string]interface{}{
"session_id": m["session_id"],
"name": m["name"],
"is_active": m["is_active"],
"updated_at": m["updated_at"],
})
}
output.PrintTable(w, rows)
})
return nil
},
}
func buildSessionListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"page_size": rctx.Int("page-size"),
}
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
params["page_token"] = token
}
return params
}

View File

@@ -1,89 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsSessionList_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/sessions",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"sessions": []interface{}{
map[string]interface{}{
"session_id": "conv_a", "name": "建后台", "is_active": true,
"created_at": "2026-05-28T10:00:00Z", "updated_at": "2026-05-28T11:00:00Z",
},
},
"next_page_token": "",
"has_more": false,
},
},
})
if err := runAppsShortcut(t, AppsSessionList,
[]string{"+session-list", "--app-id", "app_x", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"session_id": "conv_a"`) {
t.Fatalf("stdout missing session: %s", got)
}
}
func TestAppsSessionList_TableShowsKeyColumns(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/sessions",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"sessions": []interface{}{
map[string]interface{}{"session_id": "conv_a", "name": "建后台", "is_active": true, "updated_at": "2026-05-28T11:00:00Z"},
},
},
},
})
if err := runAppsShortcut(t, AppsSessionList,
[]string{"+session-list", "--app-id", "app_x", "--format", "table", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "conv_a") || !strings.Contains(got, "建后台") {
t.Fatalf("table missing key columns: %s", got)
}
}
func TestAppsSessionList_PassesPagination(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsSessionList,
[]string{"+session-list", "--app-id", "app_x", "--page-size", "50", "--page-token", "tok1", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "page_size") || !strings.Contains(got, "50") {
t.Fatalf("dry-run missing page_size: %s", got)
}
if !strings.Contains(got, "tok1") {
t.Fatalf("dry-run missing page_token: %s", got)
}
}
func TestAppsSessionList_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsSessionList, []string{"+session-list", "--app-id", "", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected --app-id required error, got %v", err)
}
}

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const sessionStopHint = "verify --app-id and --session-id are correct (list sessions with `lark-cli apps +session-list --app-id <app_id>`); --turn-id must be the latest turn from `lark-cli apps +session-get --app-id <app_id> --session-id <session_id>`"
// AppsSessionStop interrupts the RUNNING turn of a session. No-op if the turn
// is queued or already finished. Does not close the session.
var AppsSessionStop = common.Shortcut{
Service: appsService,
Command: "+session-stop",
Description: "Stop (interrupt) the running turn of a session",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +session-stop --app-id <app_id> --session-id <session_id> --turn-id <turn_id>",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "session-id", Desc: "session ID", Required: true},
{Name: "turn-id", Desc: "turn ID to stop (from +session-get latest_turn.turn_id)", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
if strings.TrimSpace(rctx.Str("session-id")) == "" {
return output.ErrValidation("--session-id is required")
}
if strings.TrimSpace(rctx.Str("turn-id")) == "" {
return output.ErrValidation("--turn-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(stopPath(rctx.Str("app-id"), rctx.Str("session-id"))).
Desc("Stop the running turn of a session").
Body(buildStopBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("POST", stopPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, buildStopBody(rctx))
if err != nil {
return withAppsHint(err, sessionStopHint)
}
turnID := strings.TrimSpace(rctx.Str("turn-id"))
rctx.OutFormat(data, nil, func(w io.Writer) {
stopped, _ := data["stopped"].(bool)
if stopped {
fmt.Fprintf(w, "stopped turn %s. %v\n", turnID, data["message"])
} else {
fmt.Fprintf(w, "no-op: turn %s not stopped. %v\n", turnID, data["message"])
}
})
return nil
},
}
func stopPath(appID, sessionID string) string {
return sessionPath(appID, sessionID) + "/stop"
}
func buildStopBody(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"turn_id": strings.TrimSpace(rctx.Str("turn-id")),
}
}

View File

@@ -1,110 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsSessionStop_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"stopped": true, "message": "running turn stopped"},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsSessionStop,
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "8421374923", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["turn_id"] != "8421374923" {
t.Fatalf("body.turn_id = %v", sent["turn_id"])
}
}
func TestAppsSessionStop_PrettyStopped(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"stopped": true, "message": "running turn stopped"}},
})
if err := runAppsShortcut(t, AppsSessionStop,
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "8421374923", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "stopped turn 8421374923") {
t.Fatalf("pretty stopped wrong: %q", got)
}
}
func TestAppsSessionStop_PrettyNoOp(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"stopped": false, "message": "turn already completed"}},
})
if err := runAppsShortcut(t, AppsSessionStop,
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "t1", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "no-op") || !strings.Contains(got, "completed") {
t.Fatalf("pretty no-op wrong: %q", got)
}
}
func TestAppsSessionStop_RequiresTurnID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsSessionStop,
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "turn-id") {
t.Fatalf("expected --turn-id required error, got %v", err)
}
}
func TestAppsSessionStop_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsSessionStop,
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "t1", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, `"turn_id": "t1"`) {
t.Fatalf("dry-run missing turn_id body: %s", got)
}
}
// Encoding safeguard for the shared sessionPath helper (reused from Task 3).
func TestAppsSessionStop_EncodesPathSegments(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsSessionStop,
[]string{"+session-stop", "--app-id", "a/b", "--session-id", "c/d", "--turn-id", "t1", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if strings.Contains(got, "apps/a/b/sessions") || strings.Contains(got, "sessions/c/d/stop") {
t.Fatalf("path segments must be encoded, got raw slash: %s", got)
}
}

View File

@@ -1,327 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
// frameworkGlobalFlags are injected by shortcuts/common/runner.go for every (or
// many) shortcuts, so they are always allowed in skill docs regardless of which
// command they are attached to. See registerShortcutFlagsWithContext in
// shortcuts/common/runner.go: --dry-run, --format, --json, --jq/-q are injected
// unconditionally; --as via the identity flag; --yes for high-risk-write;
// --print-schema/--flag-name for shortcuts that opt into schema introspection;
// --help/-h are cobra built-ins.
var frameworkGlobalFlags = map[string]bool{
"dry-run": true, "format": true, "json": true, "yes": true,
"jq": true, "q": true, "as": true,
"print-schema": true, "flag-name": true, "help": true, "h": true,
}
// cmdRef is one apps command invocation extracted from a skill doc.
type cmdRef struct {
cmd string // registered command form, includes the leading '+'
flags []string // long flag names without '--', short flags without '-'
}
var (
// cmdTokenRe matches a shortcut command token. The leading '+' is the
// reliable signal; the body is a-z plus digits/hyphens. A real command
// never ends in '-', so a trailing hyphen (from a glob like `+db-*`) is
// stripped/rejected separately.
cmdTokenRe = regexp.MustCompile(`\+[a-z][a-z0-9-]*`)
longFlagRe = regexp.MustCompile(`^--([a-z][a-z0-9-]*)`)
shortFlagRe = regexp.MustCompile(`^-([a-z])$`)
// bareWordRe matches a plain lowercase word (a CLI service/qualifier token
// like "apps", "contact", "im", "code") with no markdown decoration.
bareWordRe = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
)
// documentedNonexistentExamples are apps-prefixed command tokens the lark-apps
// docs deliberately cite as NOT being apps commands (negative examples that
// warn agents away from inventing them). They are intentionally absent from
// Shortcuts(); excluding them here is narrow and explicit, unlike skipping any
// line containing "不存在" (a common Chinese word meaning "does not exist")
// which would mask real drift on unrelated lines.
//
// Source lines (both files carry the identical sentence):
// - skills/lark-apps/references/lark-apps-local-dev.md:52
// - skills/lark-apps/references/lark-apps-git-credential.md:35
// "...不存在 `apps +pull` / `apps +push` / `apps code +read` 这类...shortcut..."
//
// Only `+pull` and `+push` are apps-prefixed and thus need an explicit entry
// here. `apps code +read` is preceded by the bare qualifier word "code" (not
// "apps"), so the cross-service filter already rejects it; adding it would be
// redundant double-coverage, so it is intentionally omitted.
var documentedNonexistentExamples = map[string]bool{
"+pull": true,
"+push": true,
}
// extractCmdRefs joins backslash-continued lines, then for each `+<cmd>` token
// captures the --flags/-q that follow it, stopping at the next `+<cmd>` token, a
// shell separator (| && ;), or the end of the inline-code span the command
// appears in. Flags only attach within the same backtick-delimited segment as
// the command, because skill docs write a real invocation inside one code span
// (`lark-cli apps +create --name x`) while a stray `--flag` discussed in prose
// (e.g. "`+git-credential-list` ... 不需要 `--app-id`") lives in a separate
// span and must not attach.
//
// To avoid false positives it also:
// - skips a `+token` immediately preceded by a bare service/qualifier word
// other than "apps" (e.g. `contact +search-user`, `im +chat-search`,
// `apps code +read`) — those are not apps shortcuts;
// - rejects a token that ends in '-' (a wildcard family like `+db-*`,
// `+release-*`), since no registered command ends in a hyphen.
//
// Deliberate negative examples (documentedNonexistentExamples, e.g. `+pull`)
// are still extracted here; the consistency gate skips them explicitly when an
// unregistered command turns out to be one of those documented examples.
func extractCmdRefs(doc string) []cmdRef {
var refs []cmdRef
for _, logical := range logicalLines(doc) {
// Split the logical line into backtick-delimited segments. A command and
// its flags only travel together within one segment; crossing a backtick
// boundary resets the capture context. Code-block lines (no backticks)
// are a single segment and behave like a normal command line.
var cur *cmdRef
var prevClean string
for _, seg := range strings.Split(logical, "`") {
cur = nil // a new inline span never inherits the previous command
prevClean = ""
for _, tok := range strings.Fields(seg) {
clean := strings.Trim(tok, ",'\"()*")
if tok == "|" || tok == "&&" || tok == ";" {
cur = nil
prevClean = clean
continue
}
if strings.HasPrefix(clean, "+") {
m := cmdTokenRe.FindString(clean)
if m == "" || strings.HasSuffix(m, "-") {
// Not a real command shape (e.g. "+1") or a wildcard
// family like "+db-" from `+db-*`. No capture context.
cur = nil
prevClean = clean
continue
}
// Cross-service reference: nearest preceding bare word is a
// service/qualifier other than "apps".
if bareWordRe.MatchString(prevClean) && prevClean != "apps" && prevClean != "lark-cli" {
cur = nil
prevClean = clean
continue
}
refs = append(refs, cmdRef{cmd: m})
cur = &refs[len(refs)-1]
prevClean = clean
continue
}
if cur != nil {
if m := longFlagRe.FindStringSubmatch(clean); m != nil {
cur.flags = append(cur.flags, m[1])
} else if m := shortFlagRe.FindStringSubmatch(clean); m != nil {
cur.flags = append(cur.flags, m[1])
}
}
prevClean = clean
}
}
}
return refs
}
// logicalLines merges lines ending with a backslash into one logical line.
func logicalLines(doc string) []string {
raw := strings.Split(strings.ReplaceAll(doc, "\r\n", "\n"), "\n")
var out []string
var buf strings.Builder
carrying := false
for _, ln := range raw {
t := strings.TrimRight(ln, " \t")
if strings.HasSuffix(t, "\\") {
buf.WriteString(strings.TrimSuffix(t, "\\"))
buf.WriteString(" ")
carrying = true
continue
}
buf.WriteString(ln)
out = append(out, buf.String())
buf.Reset()
carrying = false
}
if carrying || buf.Len() > 0 {
out = append(out, buf.String())
}
return out
}
func TestExtractCmdRefs_Unit(t *testing.T) {
doc := "`lark-cli apps +create --name x --app-type html`\n" +
"`+db-table-list`, `+db-table-get`\n" +
"lark-cli apps +session-list --app-id x | jq '.y --post-pipe-flag'\n" +
"lark-cli apps +foo --bar baz \\\n --qux 1\n" +
"人名→`ou_` 用 `lark-cli contact +search-user --query <名字>`,群名→`oc_` 用 `lark-cli im +chat-search --query <群名>`\n" +
"改库走 `+db-*`;发布走 `+release-*`\n" +
"不存在 `apps +pull` / `apps +push` / `apps code +read` 这类 shortcut不要臆造。\n" +
"`+git-credential-list` 列出本地凭证,不需要 `--app-id`。\n"
refs := extractCmdRefs(doc)
got := map[string][]string{}
for _, r := range refs {
got[r.cmd] = append(got[r.cmd], r.flags...)
}
// Full invocation: command + both flags captured.
if _, ok := got["+create"]; !ok {
t.Fatalf("missing +create; got %+v", refs)
}
if !contains(got["+create"], "name") || !contains(got["+create"], "app-type") {
t.Errorf("+create flags wrong: %v", got["+create"])
}
// Comma-separated command list: no flags attach to either command.
if _, ok := got["+db-table-list"]; !ok {
t.Errorf("missing +db-table-list; got %+v", refs)
}
if len(got["+db-table-list"]) != 0 || len(got["+db-table-get"]) != 0 {
t.Errorf("comma-separated commands must carry no flags: %v", got)
}
// Pipe stops capture within a SINGLE span (no surrounding backticks), so the
// pipe `|` is the only boundary that can stop flag capture here: --app-id
// (before the pipe) attaches, but the post-pipe --post-pipe-flag must NOT.
if !contains(got["+session-list"], "app-id") {
t.Errorf("pre-pipe flag should attach to +session-list: %v", got["+session-list"])
}
if contains(got["+session-list"], "post-pipe-flag") {
t.Errorf("pipe did not stop flag capture: %v", got["+session-list"])
}
// Backslash continuation joins --qux onto +foo (same logical line).
if !contains(got["+foo"], "bar") || !contains(got["+foo"], "qux") {
t.Errorf("continuation join failed: %v", got["+foo"])
}
// Cross-service commands must NOT be attributed to apps.
if _, ok := got["+search-user"]; ok {
t.Errorf("contact +search-user must not be extracted as an apps command: %+v", refs)
}
if _, ok := got["+chat-search"]; ok {
t.Errorf("im +chat-search must not be extracted as an apps command: %+v", refs)
}
// Wildcard family references must NOT be extracted as commands.
if _, ok := got["+db-"]; ok {
t.Errorf("`+db-*` wildcard must not be extracted as a command: %+v", refs)
}
if _, ok := got["+release-"]; ok {
t.Errorf("`+release-*` wildcard must not be extracted as a command: %+v", refs)
}
// Deliberate negative examples are no longer line-skipped: the apps-prefixed
// `+pull` / `+push` ARE extracted here (the consistency gate later excludes
// them via documentedNonexistentExamples). `apps code +read` is preceded by
// the bare qualifier "code", so the cross-service filter still drops it.
for _, tok := range []string{"+pull", "+push"} {
if _, ok := got[tok]; !ok {
t.Errorf("negative example %s should still be extracted (gate excludes it, not the extractor): %+v", tok, refs)
}
if !documentedNonexistentExamples[tok] {
t.Errorf("%s must be in documentedNonexistentExamples allowlist", tok)
}
}
if _, ok := got["+read"]; ok {
t.Errorf("`apps code +read` is cross-service (preceded by `code`) and must not be extracted: %+v", refs)
}
// A --flag discussed in prose, in a separate inline-code span from the
// command, must NOT attach to the command (backtick-span boundary stops
// capture).
if contains(got["+git-credential-list"], "app-id") {
t.Errorf("prose flag in a separate backtick span must not attach: %v", got["+git-credential-list"])
}
}
func TestSkillDocsCommandsConsistentWithShortcuts(t *testing.T) {
// Source of truth: the registered shortcuts and their flags.
validCmd := map[string]map[string]bool{}
for _, s := range Shortcuts() {
fl := map[string]bool{}
for _, f := range s.Flags {
fl[f.Name] = true
}
validCmd[s.Command] = fl
}
docs := skillDocFiles(t)
if len(docs) == 0 {
t.Fatal("no lark-apps skill docs found; gate cannot run")
}
for _, path := range docs {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
rel := filepath.Base(path)
for _, ref := range extractCmdRefs(string(raw)) {
flags, ok := validCmd[ref.cmd]
if !ok {
// A deliberate negative example (documented as NOT existing) is
// expected to be absent from Shortcuts(); skip only those.
if documentedNonexistentExamples[ref.cmd] {
continue
}
t.Errorf("%s: references `apps %s` which is not a registered shortcut", rel, ref.cmd)
continue
}
for _, fl := range ref.flags {
if flags[fl] || frameworkGlobalFlags[fl] {
continue
}
t.Errorf("%s: `apps %s --%s`: --%s is not a flag of %s (have: %s)",
rel, ref.cmd, fl, fl, ref.cmd, sortedFlags(flags))
}
}
}
}
// skillDocFiles returns SKILL.md + references/*.md for lark-apps, relative to
// this package dir (go test cwd = shortcuts/apps/).
func skillDocFiles(t *testing.T) []string {
t.Helper()
base := filepath.Join("..", "..", "skills", "lark-apps")
var out []string
if _, err := os.Stat(filepath.Join(base, "SKILL.md")); err == nil {
out = append(out, filepath.Join(base, "SKILL.md"))
}
refs, _ := filepath.Glob(filepath.Join(base, "references", "*.md"))
out = append(out, refs...)
return out
}
func sortedFlags(m map[string]bool) string {
names := make([]string, 0, len(m))
for n := range m {
names = append(names, n)
}
sort.Strings(names)
return strings.Join(names, ", ")
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}

View File

@@ -20,13 +20,9 @@ var AppsUpdate = common.Shortcut{
Command: "+update",
Description: "Partially update a Miaoda app (only provided fields are sent)",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +update --app-id <app_id> --name "新名称"`,
`Example: lark-cli apps +update --app-id <app_id> --description "..."`,
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "name", Desc: "new app display name"},
@@ -52,9 +48,9 @@ var AppsUpdate = common.Shortcut{
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("PATCH", path, nil, buildAppsUpdateBody(rctx))
data, err := rctx.CallAPI("PATCH", path, nil, buildAppsUpdateBody(rctx))
if err != nil {
return withAppsHint(err, appIDListHint)
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "updated: %s\n", common.GetString(data, "app", "app_id"))

View File

@@ -8,46 +8,9 @@ import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func testRuntimeWithNameDesc(t *testing.T, name, desc string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "update"}
cmd.Flags().String("name", name, "")
cmd.Flags().String("description", desc, "")
return common.TestNewRuntimeContext(cmd, nil)
}
func TestBuildAppsUpdateBody_FieldCombos(t *testing.T) {
t.Run("both empty -> empty body", func(t *testing.T) {
if body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, " ", "")); len(body) != 0 {
t.Errorf("empty inputs should yield empty body, got %v", body)
}
})
t.Run("name only", func(t *testing.T) {
body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, "App", ""))
if body["name"] != "App" || len(body) != 1 {
t.Errorf("name-only body=%v", body)
}
})
t.Run("description only", func(t *testing.T) {
body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, "", "desc"))
if body["description"] != "desc" || len(body) != 1 {
t.Errorf("desc-only body=%v", body)
}
})
t.Run("both set and trimmed", func(t *testing.T) {
body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, " App ", " d "))
if body["name"] != "App" || body["description"] != "d" {
t.Errorf("both body=%v", body)
}
})
}
func TestAppsUpdate_PartialFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{

View File

@@ -1,48 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"os/exec"
"regexp"
)
// commandRunner abstracts external process execution so apps +init's
// orchestration can be unit-tested without a real git binary or network.
// dir == "" runs in the current working directory; a non-empty dir runs the
// command with that working directory (git -C semantics).
type commandRunner interface {
Run(ctx context.Context, dir, name string, args ...string) (stdout, stderr string, err error)
}
// execCommandRunner is the production commandRunner backed by os/exec.
type execCommandRunner struct{}
func (execCommandRunner) Run(ctx context.Context, dir, name string, args ...string) (string, string, error) {
cmd := exec.CommandContext(ctx, name, args...)
if dir != "" {
cmd.Dir = dir
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return stdout.String(), stderr.String(), err
}
// credentialURLRe matches the userinfo segment of an http(s) URL (the
// "user:token@" part) so it can be redacted before any output or logging. The
// negated class excludes only "/" and whitespace (not "@"), so the match
// greedily consumes up to the LAST "@" before the host/path — this ensures a
// literal "@" inside the userinfo (e.g. "user:p@ss@host") is fully redacted.
var credentialURLRe = regexp.MustCompile(`(?i)(https?://)[^/\s]+@`)
// redactURLCredentials replaces the userinfo segment of any http(s) URL in s
// with "***". Safe to call on both a bare repo_url and free-form text such as
// git stderr (which echoes the full remote URL on failure).
func redactURLCredentials(s string) string {
return credentialURLRe.ReplaceAllString(s, "${1}***@")
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
)
func TestRedactURLCredentials(t *testing.T) {
cases := []struct{ name, in, want string }{
{"http with userinfo", "http://x-token:PAT_abc@git.host/app_x.git", "http://***@git.host/app_x.git"},
{"https with userinfo", "https://u:p@h/r.git", "https://***@h/r.git"},
{"no userinfo unchanged", "http://git.host/app_x.git", "http://git.host/app_x.git"},
{"embedded in stderr text", "fatal: unable to access 'http://u:t@h/r.git/': 401", "fatal: unable to access 'http://***@h/r.git/': 401"},
{"empty", "", ""},
{"non-url unchanged", "some error message", "some error message"},
{"uppercase scheme", "HTTP://u:t@h/r.git", "HTTP://***@h/r.git"},
{"multiple @ in userinfo", "https://user:p@ss@host/r.git", "https://***@host/r.git"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := redactURLCredentials(c.in); got != c.want {
t.Errorf("redactURLCredentials(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
// fakeCommandRunner records calls and returns scripted results keyed by the
// command + first arg (e.g. "git clone", "git checkout", "git status"), or
// "credential-init" for the self-invoked `apps +git-credential-init` call.
type fakeCallResult struct {
stdout, stderr string
err error
}
type fakeCommandRunner struct {
results map[string]fakeCallResult
calls [][]string // each entry: [dir, name, args...]
}
func (f *fakeCommandRunner) Run(ctx context.Context, dir, name string, args ...string) (string, string, error) {
rec := append([]string{dir, name}, args...)
f.calls = append(f.calls, rec)
key := name
if len(args) > 0 {
key = name + " " + args[0]
}
if name != "git" && len(args) >= 2 && args[0] == "apps" {
switch args[1] {
case "+env-pull":
key = "env-pull"
default:
key = "credential-init"
}
}
if r, ok := f.results[key]; ok {
return r.stdout, r.stderr, r.err
}
return "", "", nil
}

View File

@@ -3,50 +3,8 @@
package apps
import (
"errors"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
// appsService 是 CLI 命令的 service 前缀lark-cli apps ...)。
const appsService = "apps"
// apiBasePath is the registered OAPI prefix for the Miaoda apps domain.
const apiBasePath = "/open-apis/spark/v1"
// appIDListHint is the shared recovery hint for commands whose most likely
// failure cause is a wrong/inaccessible --app-id. It points at +list to find
// the correct Miaoda app id. The app_/cli_ format rule is taught in
// lark-apps SKILL.md ("app_id 获取"); the hint stays lean and does not repeat it.
const appIDListHint = "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`"
// withAppsHint attaches an actionable next-step hint to a failure returned by
// CallAPI, preserving its original classification (typed subtype/code/log_id or
// legacy detail). A hint already present on the error is kept (the upstream
// wording wins); only an empty hint is filled in. Mirrors
// drive.appendDriveExportRecoveryHint. err==nil passes through.
func withAppsHint(err error, hint string) error {
if err == nil {
return nil
}
// p points at the embedded Problem, so the mutation is reflected in err.
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) == "" {
p.Hint = hint
}
return err
}
// Legacy *output.ExitError fallback: fill the hint in place, preserving the
// original class / exit code rather than downgrading the error.
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) == "" {
exitErr.Detail.Hint = hint
}
return err
}
return err
}

View File

@@ -1,66 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/output"
)
func TestWithAppsHint(t *testing.T) {
t.Run("nil error stays nil", func(t *testing.T) {
if got := withAppsHint(nil, "do x"); got != nil {
t.Fatalf("withAppsHint(nil) = %v, want nil", got)
}
})
t.Run("empty hint gets filled, code/type preserved", func(t *testing.T) {
in := &output.ExitError{Code: 1, Detail: &output.ErrDetail{Type: "api_error", Message: "boom"}}
out := withAppsHint(in, "run +release-list")
var exitErr *output.ExitError
if !errors.As(out, &exitErr) {
t.Fatalf("returned error is not *output.ExitError: %T", out)
}
if exitErr.Detail.Hint != "run +release-list" {
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "run +release-list")
}
if exitErr.Code != 1 || exitErr.Detail.Type != "api_error" || exitErr.Detail.Message != "boom" {
t.Errorf("code/type/message mutated: code=%d type=%q msg=%q", exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message)
}
})
t.Run("existing hint is preserved, not clobbered", func(t *testing.T) {
in := output.ErrWithHint(1, "api_error", "boom", "original hint")
out := withAppsHint(in, "new hint")
var exitErr *output.ExitError
if !errors.As(out, &exitErr) {
t.Fatalf("returned error is not *output.ExitError: %T", out)
}
if exitErr.Detail.Hint != "original hint" {
t.Errorf("Hint = %q, want preserved %q", exitErr.Detail.Hint, "original hint")
}
})
t.Run("blank-whitespace hint is treated as empty and filled", func(t *testing.T) {
in := output.ErrWithHint(1, "api_error", "boom", " ")
out := withAppsHint(in, "filled hint")
var exitErr *output.ExitError
if !errors.As(out, &exitErr) {
t.Fatalf("returned error is not *output.ExitError: %T", out)
}
if exitErr.Detail.Hint != "filled hint" {
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "filled hint")
}
})
t.Run("unrecognized error type returned unchanged, no panic", func(t *testing.T) {
in := errors.New("plain")
out := withAppsHint(in, "ignored")
if out == nil || out.Error() != "plain" {
t.Fatalf("withAppsHint(plain) = %v, want unchanged plain error", out)
}
})
}

View File

@@ -1,236 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
)
// pollUntil 轮询异步任务直到 check 判定终态。async migrate/recovery 用dataloom 立即返
// task_id/preview_request_idCLI 自己 poll避免单连接长挂被网关/SDK 30s 中断)。
// 首次立即 fetch不睡check 返 done→返回返 err→透传失败终态否则按 interval 间隔重试至 maxWait。
func pollUntil(ctx context.Context, interval, maxWait time.Duration,
fetch func() (map[string]interface{}, error),
check func(map[string]interface{}) (done bool, err error)) (map[string]interface{}, error) {
maxAttempts := int(maxWait / interval)
if maxAttempts < 1 {
maxAttempts = 1
}
for i := 0; ; i++ {
data, err := fetch()
if err != nil {
return nil, err
}
done, cerr := check(data)
if cerr != nil {
return nil, cerr
}
if done {
return data, nil
}
if i+1 >= maxAttempts {
// async 任务多半还在服务端推进poll 超时是可重试的——标 retryable 让 agent 重新轮询而非放弃。
return nil, errs.NewNetworkError(errs.SubtypeNetworkTimeout, "timed out waiting for completion after %s", maxWait).WithRetryable()
}
select {
case <-ctx.Done():
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "cancelled while waiting").WithCause(ctx.Err())
case <-time.After(interval):
}
}
}
// URL helpers for the db CLI commands.
// appTablesPath 返回 app db 表列表 URL复用存量「获取数据表列表」接口
func appTablesPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/tables", apiBasePath, validate.EncodePathSegment(appID))
}
// appTablePath 返回单个 app db 表详情 URL复用存量「获取数据表详细信息」接口
func appTablePath(appID, table string) string {
return appTablesPath(appID) + "/" + validate.EncodePathSegment(table)
}
// appSQLPath 返回 app db SQL 执行 URL复用存量「执行 SQL」接口
func appSQLPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/sql_commands", apiBasePath, validate.EncodePathSegment(appID))
}
// appDbEnvCreatePath 返回 app db 环境创建 URL服务端接口名仍为 db_dev_init
func appDbEnvCreatePath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db_dev_init", apiBasePath, validate.EncodePathSegment(appID))
}
// ── 多环境发布env diff/migrate/ 数据恢复recovery/ 配额 路由 ──
// appEnvMigratePath 返回 dev→online 发布(预览/落地共用URLdb/env_migrate。
func appEnvMigratePath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/env_migrate", apiBasePath, validate.EncodePathSegment(appID))
}
// appEnvMigrateStatusPath 返回发布异步任务状态查询 URLdb/env_migrate_status。
func appEnvMigrateStatusPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/env_migrate_status", apiBasePath, validate.EncodePathSegment(appID))
}
// appRecoveryPath 返回 PITR 数据恢复(预览/落地共用URLdb/env_recovery。
func appRecoveryPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/env_recovery", apiBasePath, validate.EncodePathSegment(appID))
}
// appRecoveryDiffStatusPath 返回恢复预览diff异步状态查询 URLdb/env_recovery_diff_status。
func appRecoveryDiffStatusPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/env_recovery_diff_status", apiBasePath, validate.EncodePathSegment(appID))
}
// appRecoveryApplyStatusPath 返回恢复落地异步状态查询 URLdb/env_recovery_apply_status。
func appRecoveryApplyStatusPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/env_recovery_apply_status", apiBasePath, validate.EncodePathSegment(appID))
}
// appDbQuotaPath 返回 db 配额查询 URLdb/quota。
func appDbQuotaPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/quota", apiBasePath, validate.EncodePathSegment(appID))
}
// ── 变更追溯changelog / audit路由 ──
// appChangelogListPath 返回 DDL 变更记录列表 URLdb/changelog_list。
func appChangelogListPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/changelog_list", apiBasePath, validate.EncodePathSegment(appID))
}
// appAuditStatusPath 返回表审计开关状态查询 URLdb/audit_status。
func appAuditStatusPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/audit_status", apiBasePath, validate.EncodePathSegment(appID))
}
// appAuditSetPath 返回表审计开关设置 URLdb/audit_set。
func appAuditSetPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/audit_set", apiBasePath, validate.EncodePathSegment(appID))
}
// appAuditListPath 返回行级审计事件列表 URLdb/audit_list。
func appAuditListPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/audit_list", apiBasePath, validate.EncodePathSegment(appID))
}
// operatorRef 是 operator 的 {id,name}。后端用 JSON 字符串内嵌透传CLI parse
// json 输出还原成对象下游能区分同名用户pretty 只取 name。
type operatorRef struct {
ID string `json:"id"`
Name string `json:"name"`
}
// parseOperator 解析 operator 字符串空→nil非 JSON→{raw,raw}JSON→{id,name}name 空兜底 id
func parseOperator(raw string) *operatorRef {
s := strings.TrimSpace(raw)
if s == "" {
return nil
}
if !strings.HasPrefix(s, "{") {
return &operatorRef{ID: s, Name: s}
}
var o operatorRef
if json.Unmarshal([]byte(s), &o) != nil {
return &operatorRef{ID: s, Name: s}
}
if o.Name == "" {
o.Name = o.ID
}
return &o
}
// operatorName 取 operator 的展示名pretty空用 "—"。
func operatorName(op *operatorRef) string {
if op == nil || op.Name == "" {
return "—"
}
return op.Name
}
// safeParseJSON 把 before/after 的 JSON 字符串还原成结构化对象供下游消费;失败时透传原始串。
func safeParseJSON(s string) interface{} {
var v interface{}
if json.Unmarshal([]byte(s), &v) == nil {
return v
}
return s
}
// appDataImportPath 返回 db 数据导入 URL新增 db/ 域段路由)。
func appDataImportPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/data_import", apiBasePath, validate.EncodePathSegment(appID))
}
// appDataExportPath 返回 db 数据导出 URL返原始字节
func appDataExportPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/data_export", apiBasePath, validate.EncodePathSegment(appID))
}
// appTableRecordsPath 返回数据表记录列表 URL复用 GetAppTableRecordList其 total 即符合条件的记录总数)。
func appTableRecordsPath(appID, table string) string {
return appTablePath(appID, table) + "/records"
}
// resolveDataFormat 由文件扩展名推断数据格式。lark-cli 的 --format 已被框架占用(输出渲染),
// 故数据格式从文件名推断import 接受 csv/jsonexport 还接受 sql。
func resolveDataFormat(ext string, allowSQL bool) (string, error) {
raw := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
switch raw {
case "csv", "json":
return raw, nil
case "sql":
if allowSQL {
return "sql", nil
}
}
if allowSQL {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported data format %q (file must end in .csv, .json or .sql)", raw)
}
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported data format %q (file must end in .csv or .json)", raw)
}
// countDataRows 粗估数据行数(用于导入上限校验、导出兜底计数)。
// csv非空行数 - 1表头json顶层数组长度非数组算 1解析失败算 0。
func countDataRows(body []byte, format string) int {
if format == "csv" {
lines := 0
for _, ln := range strings.Split(string(body), "\n") {
if strings.TrimRight(ln, "\r") != "" {
lines++
}
}
if lines > 0 {
return lines - 1
}
return 0
}
var arr []json.RawMessage
if err := json.Unmarshal(body, &arr); err == nil {
return len(arr)
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(body, &obj); err == nil {
return 1
}
return 0
}
// requireAppID trims --app-id and rejects blank, returning a uniform validation error.
func requireAppID(raw string) (string, error) {
id := strings.TrimSpace(raw)
if id == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
}
return id, nil
}

View File

@@ -1,41 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import "testing"
func TestAppTablesPath_ReusesExistingURL(t *testing.T) {
if got := appTablesPath("app_x"); got != "/open-apis/spark/v1/apps/app_x/tables" {
t.Fatalf("appTablesPath = %q (want existing /apps/{id}/tables, not /db/tables)", got)
}
}
func TestAppTablePath_EncodesSegments(t *testing.T) {
if got := appTablePath("app_x", "my table"); got != "/open-apis/spark/v1/apps/app_x/tables/my%20table" {
t.Fatalf("appTablePath = %q", got)
}
}
func TestAppSQLPath_ReusesExistingURL(t *testing.T) {
if got := appSQLPath("app_x"); got != "/open-apis/spark/v1/apps/app_x/sql_commands" {
t.Fatalf("appSQLPath = %q (want /apps/{id}/sql_commands)", got)
}
}
func TestAppDbEnvCreatePath_NewURL(t *testing.T) {
// db-env-create 是本期新增接口URL 走 /db_dev_init与上面三条复用 URL 不同)。
if got := appDbEnvCreatePath("app_x"); got != "/open-apis/spark/v1/apps/app_x/db_dev_init" {
t.Fatalf("appDbEnvCreatePath = %q", got)
}
}
func TestRequireAppID_BlankRejected(t *testing.T) {
if _, err := requireAppID(" "); err == nil {
t.Fatal("expected error for blank app-id")
}
got, err := requireAppID(" app_x ")
if err != nil || got != "app_x" {
t.Fatalf("requireAppID trimmed = %q err=%v", got, err)
}
}

View File

@@ -1,228 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var (
reTsRelative = regexp.MustCompile(`^([0-9]+)([smhdw])$`)
reTsDate = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}$`)
reTsLocalDateTime = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$`)
)
// normalizeTimestamp 实现设计原则三的 <timestamp> 多格式输入,统一归一化为 RFC3339 UTC
// - 相对30s / 5m / 2h / 3d / 1w从现在往前推
// - date2026-04-15本地时区 00:00:00
// - local datetime2026-04-15T10:00:00本地时区T 分隔)
// - ISO 8601 带 TZ...ZUTC/ ...+08:00显式偏移
//
// 归一化到 UTC 是必须的:服务端对无 TZ 的串按 UTC 裸解析,故 date / local datetime 的「本地」
// 语义只能在 CLI 端换算;相对时间服务端也不认。空串原样返回(调用方据此跳过该过滤)。
func normalizeTimestamp(raw string) (string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return "", nil
}
if m := reTsRelative.FindStringSubmatch(s); m != nil {
n, _ := strconv.Atoi(m[1])
var unit time.Duration
switch m[2] {
case "s":
unit = time.Second
case "m":
unit = time.Minute
case "h":
unit = time.Hour
case "d":
unit = 24 * time.Hour
case "w":
unit = 7 * 24 * time.Hour
}
return time.Now().Add(-time.Duration(n) * unit).UTC().Format(time.RFC3339), nil
}
if reTsDate.MatchString(s) {
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
if err != nil {
return "", fmt.Errorf("invalid date %q", s)
}
return t.UTC().Format(time.RFC3339), nil
}
if reTsLocalDateTime.MatchString(s) {
t, err := time.ParseInLocation("2006-01-02T15:04:05", s, time.Local)
if err != nil {
return "", fmt.Errorf("invalid local datetime %q", s)
}
return t.UTC().Format(time.RFC3339), nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t.UTC().Format(time.RFC3339), nil
}
return "", fmt.Errorf("invalid timestamp %q (want relative 7d/2h/30s, date 2026-04-15, datetime 2026-04-15T10:00:00, or ISO 8601 with TZ)", s)
}
// newFileTransferClient 直传 / 直下对象存储 presigned URL 用(绕开 Lark 网关,无需 auth、无超时以容纳大文件
//
//nolint:forbidigo // presigned object-storage transfer bypasses the Lark gateway — raw http.Client is required (no Lark auth, no gateway routing); not a Lark API call, so RuntimeContext.DoAPI does not apply.
func newFileTransferClient() *http.Client {
return &http.Client{Transport: http.DefaultTransport}
}
// URL helpers for the file (storage) CLI commands.
//
// 全部走 spark OpenAPIpath 形如 /open-apis/spark/v1/apps/{app_id}/storage/<name>。
// 路由段不含 HTTP 方法名file_get→file、file_delete→file_batch_remove、file_quota_get→file_quota
// appFileListPath 返回文件列表 URLstorage/file_list。
func appFileListPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file_list", apiBasePath, validate.EncodePathSegment(appID))
}
// appFileGetPath 返回单文件元数据 URLstorage/filefile_get→file路由不含方法名
func appFileGetPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file", apiBasePath, validate.EncodePathSegment(appID))
}
// appFileSignPath 返回临时签名下载 URL 生成接口storage/file_sign。
func appFileSignPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file_sign", apiBasePath, validate.EncodePathSegment(appID))
}
// appFilePreUploadPath 返回上传预处理(取 presigned 直传地址URLstorage/file_pre_upload。
func appFilePreUploadPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file_pre_upload", apiBasePath, validate.EncodePathSegment(appID))
}
// appFileUploadCallbackPath 返回直传完成回调登记文件URLstorage/file_upload_callback。
func appFileUploadCallbackPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file_upload_callback", apiBasePath, validate.EncodePathSegment(appID))
}
// appFileBatchRemovePath 返回批量删除文件 URLstorage/file_batch_removefile_delete→file_batch_remove
func appFileBatchRemovePath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file_batch_remove", apiBasePath, validate.EncodePathSegment(appID))
}
// appFileQuotaPath 返回存储配额查询 URLstorage/file_quotafile_quota_get→file_quota
func appFileQuotaPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file_quota", apiBasePath, validate.EncodePathSegment(appID))
}
// requireFilePath trims --path and rejects blank, returning a uniform validation error.
func requireFilePath(raw string) (string, error) {
p := strings.TrimSpace(raw)
if p == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--path is required").WithParam("--path")
}
return p, nil
}
// fileUser 是 uploaded_by 的 {id,name}。OpenAPI 以 created_by 的 JSON 字符串透传CLI parse。
type fileUser struct {
ID string `json:"id"`
Name string `json:"name"`
}
// fileInfo 是 file 命令对外输出的白名单字段。
// OpenAPI 字段 created_at / created_by → CLI 产品语义 uploaded_at / uploaded_by。
type fileInfo struct {
FileName string `json:"file_name"`
Path string `json:"path"`
SizeBytes interface{} `json:"size_bytes,omitempty"`
Type string `json:"type,omitempty"`
UploadedBy *fileUser `json:"uploaded_by,omitempty"`
UploadedAt string `json:"uploaded_at,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
}
// projectFileInfo 把 server 原始 file map 投影为 CLI fileInfocreated_*→uploaded_*)。
func projectFileInfo(m map[string]interface{}) fileInfo {
return fileInfo{
FileName: common.GetString(m, "file_name"),
Path: common.GetString(m, "path"),
SizeBytes: m["size_bytes"],
Type: common.GetString(m, "type"),
UploadedBy: parseFileUser(common.GetString(m, "created_by")),
UploadedAt: common.GetString(m, "created_at"),
DownloadURL: common.GetString(m, "download_url"),
}
}
// parseFileUser 解析 created_by 的 JSON 字符串 {id,name};空 / 非法 / 全空 → nil。
func parseFileUser(raw string) *fileUser {
s := strings.TrimSpace(raw)
if s == "" {
return nil
}
var u fileUser
if err := json.Unmarshal([]byte(s), &u); err != nil {
return nil
}
if u.ID == "" && u.Name == "" {
return nil
}
return &u
}
// normalizeTimeFlags 把若干时间 flag如 --since/--until/--uploaded-since就地归一化为 RFC3339 UTC
// 并回写,供 build*Params 透传。空 flag 跳过;非法格式 → validation 错误。复用 normalizeTimestamp。
func normalizeTimeFlags(rctx *common.RuntimeContext, flags ...string) error {
for _, f := range flags {
if strings.TrimSpace(rctx.Str(f)) == "" {
continue
}
n, err := normalizeTimestamp(rctx.Str(f))
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: %v", f, err).WithParam("--" + f)
}
_ = rctx.Cmd.Flags().Set(f, n)
}
return nil
}
// dashIfEmpty 空白串用 "—" 占位pretty 列对齐)。
func dashIfEmpty(s string) string {
if strings.TrimSpace(s) == "" {
return "—"
}
return s
}
// fileSizeDetail 把 size_bytes 渲染成 "24 KB (24580 bytes)"pretty 单文件详情用)。
func fileSizeDetail(raw interface{}) string {
n, ok := numericAsFloat(raw)
if !ok {
return "—"
}
return fmt.Sprintf("%s (%d bytes)", humanBytes(raw), int64(n))
}
// renderKeyValuePairs 输出对齐的 key: valuekey 列按最长 key 右填充)。
func renderKeyValuePairs(w io.Writer, pairs [][2]string) {
width := 0
for _, p := range pairs {
if dw := displayWidth(p[0]); dw > width {
width = dw
}
}
for _, p := range pairs {
io.WriteString(w, p[0]+":")
if pad := width - displayWidth(p[0]); pad > 0 {
io.WriteString(w, strings.Repeat(" ", pad))
}
io.WriteString(w, " "+p[1]+"\n")
}
}

View File

@@ -1,587 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"sort"
"strconv"
"strings"
"text/tabwriter"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/apps/gitcred"
"github.com/larksuite/cli/shortcuts/common"
)
const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info"
// gitCredentialIssueHint is the actionable next-step attached to a failed
// Git-credential issuance. A 5xx is flagged retryable separately at the call site.
const gitCredentialIssueHint = "failed to issue the Git credential: verify --app-id is correct and you have developer access to this Miaoda app; a 5xx is a transient server error and is safe to retry"
var AppsGitCredentialInit = common.Shortcut{
Service: appsService,
Command: "+git-credential-init",
Description: "Initialize Git credentials and a URL-scoped Git helper for a Miaoda app repository",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +git-credential-init --app-id <app_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(gitCredentialIssuePath).
Desc("Issue a Miaoda Git repository PAT").
Set("mode", "api-plus-local-setup").
Set("action", "initialize_local_git_credential").
Set("app_id", appID).
Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)).
Set("local_effects", []string{
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
}).
Params(gitCredentialIssueParams(appID))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, runtimeIssuer{rctx: rctx})
result, err := manager.Init(ctx, profileFromConfig(rctx.Config), appID)
if err != nil {
return gitCredentialLocalError("Initialize local Miaoda Git credential", err)
}
payload := map[string]interface{}{
"app_id": result.AppID,
"repository_url": result.GitHTTPURL,
"status": initStatus(result),
}
if result.ConfigWarning != "" {
payload["git_config_warning"] = result.ConfigWarning
}
rctx.OutFormat(payload, nil, func(w io.Writer) {
title := "Git credential initialized"
if result.Refreshed {
title = "Git credential refreshed"
}
fmt.Fprintln(w, title)
fmt.Fprintln(w)
fmt.Fprintf(w, "App ID: %s\n", result.AppID)
fmt.Fprintf(w, "Status: %s\n", initStatus(result))
fmt.Fprintf(w, "Repository URL: %s\n", result.GitHTTPURL)
if result.ConfigWarning != "" {
fmt.Fprintln(w)
fmt.Fprintln(w, "Git credential saved, but Git helper was not configured")
fmt.Fprintf(w, "Reason: %s\n", result.ConfigWarning)
fmt.Fprintf(w, "Next step: lark-cli apps +git-credential-init --app-id %s\n", result.AppID)
return
}
fmt.Fprintln(w)
fmt.Fprintln(w, "Next step:")
fmt.Fprintf(w, " git clone %s\n", result.GitHTTPURL)
})
return nil
},
}
var AppsGitCredentialRemove = common.Shortcut{
Service: appsService,
Command: "+git-credential-remove",
Description: "Remove local Git credentials and the URL-scoped Git helper for a Miaoda app repository",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +git-credential-remove --app-id <app_id>",
},
Scopes: []string{},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
Desc("Preview local Git credential cleanup (no API call; would clean up local-only state).").
Set("mode", "local-cleanup-only").
Set("action", "remove_local_git_credential").
Set("app_id", appID).
Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)).
Set("effects", []string{
"read app-scoped git credential metadata",
"remove the saved PAT from the local system credential store",
"remove the app-scoped Git helper from global git config when present",
"delete the local metadata record after cleanup succeeds",
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, nil)
result, err := manager.Remove(ctx, profileFromConfig(rctx.Config), appID)
if err != nil {
return gitCredentialLocalError("Remove local Miaoda Git credential", err)
}
payload := map[string]interface{}{
"app_id": result.AppID,
"removed": result.Removed,
}
if result.ConfigWarning != "" {
payload["git_config_warning"] = result.ConfigWarning
}
rctx.OutFormat(payload, nil, func(w io.Writer) {
if !result.Removed {
fmt.Fprintln(w, "No local Git credential found")
return
}
fmt.Fprintln(w, "Git credential removed")
fmt.Fprintln(w)
fmt.Fprintf(w, "App ID: %s\n", result.AppID)
if len(result.Records) > 0 {
fmt.Fprintf(w, "Repository URL: %s\n", result.Records[0].GitHTTPURL)
}
fmt.Fprintln(w, "Status: removed")
if result.ConfigWarning != "" {
fmt.Fprintln(w)
fmt.Fprintln(w, "Git config cleanup warning")
fmt.Fprintf(w, "Reason: %s\n", result.ConfigWarning)
}
})
return nil
},
}
var AppsGitCredentialList = common.Shortcut{
Service: appsService,
Command: "+git-credential-list",
Description: "List local Git credentials for Miaoda app repositories",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +git-credential-list",
},
Scopes: []string{},
AuthTypes: []string{"user"},
HasFormat: true,
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Preview local Git credential listing (no API call, read-only local state).").
Set("mode", "local-read-only").
Set("action", "list_local_git_credentials").
Set("storage_root", filepath.Join(core.GetConfigDir(), storageRoot)).
Set("reads", []string{
"scan app-scoped git credential metadata under the CLI config directory",
"derive per-app repository URLs and local credential status from local metadata",
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now)
if err != nil {
return gitCredentialLocalError("List local Miaoda Git credentials", err)
}
payload := map[string]interface{}{
"count": len(records),
"credentials": gitCredentialListPayload(records),
}
rctx.OutFormat(payload, nil, func(w io.Writer) {
if len(records) == 0 {
fmt.Fprintln(w, "No Git credentials initialized")
fmt.Fprintln(w)
fmt.Fprintln(w, "Next step: lark-cli apps +git-credential-init --app-id <app_id>")
return
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "App ID\tRepository URL\tStatus")
for _, record := range records {
fmt.Fprintf(tw, "%s\t%s\t%s\n", record.AppID, record.GitHTTPURL, gitCredentialDisplayStatus(record.Status))
}
_ = tw.Flush()
fmt.Fprintln(w)
fmt.Fprintln(w, "Profile switches do not remove old URL-scoped Git helpers automatically.")
fmt.Fprintln(w, "Cleanup: lark-cli apps +git-credential-remove --app-id <app_id>")
})
return nil
},
}
// InstallOnApps attaches hidden, apps-domain commands that are not regular
// shortcuts. git-credential-helper must speak Git's stdin/stdout protocol
// directly, so it intentionally does not use the shortcut JSON envelope.
func InstallOnApps(parent *cobra.Command, f *cmdutil.Factory) {
parent.AddCommand(newGitCredentialHelperCommand(f))
}
func newGitCredentialHelperCommand(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "git-credential-helper get|store|erase",
Short: "Git credential helper for Miaoda app repositories",
Hidden: true,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
appID, _ := cmd.Flags().GetString("app-id")
return runGitCredentialHelper(cmd.Context(), f, strings.TrimSpace(appID), args[0])
},
}
cmd.Flags().String("app-id", "", "Miaoda app ID")
_ = cmd.Flags().MarkHidden("app-id")
return cmd
}
type runtimeIssuer struct {
rctx *common.RuntimeContext
}
func (i runtimeIssuer) Issue(ctx context.Context, appID string, profile gitcred.ProfileContext) (*gitcred.IssuedCredential, error) {
resp, err := i.rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: issuePath(appID),
})
data, err := parseIssueCredentialData(resp, err)
if err != nil {
return nil, err
}
return issuedFromData(appID, data)
}
type factoryIssuer struct {
f *cmdutil.Factory
}
func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.ProfileContext) (*gitcred.IssuedCredential, error) {
cfg, err := i.f.Config()
if err != nil {
return nil, err
}
if cfg.UserOpenId == "" {
return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`")
}
ac, err := i.f.NewAPIClientWithConfig(cfg)
if err != nil {
return nil, err
}
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: issuePath(appID),
}
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
data, err := parseIssueCredentialData(resp, err)
if err != nil {
return nil, err
}
return issuedFromData(appID, data)
}
func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, action string) error {
if f == nil || f.IOStreams == nil {
return nil
}
if appID == "" {
fmt.Fprintln(f.IOStreams.ErrOut, "Git credential unavailable: missing app_id; rerun lark-cli apps +git-credential-init --app-id <app_id>")
return nil
}
manager := newGitCredentialManager(appID, f.Keychain, factoryIssuer{f: f})
switch action {
case "get":
input, err := gitcred.ParseCredentialInput(f.IOStreams.In)
if err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "Git credential unavailable: %s\n", err)
return nil
}
cfg, err := f.Config()
if err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "Git credential unavailable: %s\n", err)
return nil
}
return manager.Get(ctx, input, profileFromConfig(cfg), f.IOStreams.Out, f.IOStreams.ErrOut)
case "store":
return manager.StoreCredential(f.IOStreams.In)
case "erase":
return manager.Erase(f.IOStreams.In)
default:
fmt.Fprintf(f.IOStreams.ErrOut, "unsupported git credential action %q\n", action)
return nil
}
}
func newGitCredentialManager(appID string, kc keychain.KeychainAccess, issuer gitcred.Issuer) *gitcred.Manager {
storage := gitCredentialAppStorage{}
return gitcred.NewManager(gitcred.NewAppStore(appID, storage), gitcred.NewSecretStore(kc), gitcred.GlobalGitConfig{}, issuer)
}
func listGitCredentialRecords(kc keychain.KeychainAccess, now func() time.Time) ([]gitcred.ListRecord, error) {
storage := gitCredentialAppStorage{}
appIDs, err := storage.ListAppIDs()
if err != nil {
return nil, err
}
records := make([]gitcred.ListRecord, 0, len(appIDs))
for _, appID := range appIDs {
manager := newGitCredentialManager(appID, kc, nil)
manager.Now = now
result, err := manager.List()
if err != nil {
return nil, err
}
records = append(records, result.Records...)
}
sort.Slice(records, func(i, j int) bool {
if records[i].AppID == records[j].AppID {
return records[i].GitHTTPURL < records[j].GitHTTPURL
}
return records[i].AppID < records[j].AppID
})
return records, nil
}
func gitCredentialListPayload(records []gitcred.ListRecord) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
out = append(out, map[string]interface{}{
"app_id": record.AppID,
"repository_url": record.GitHTTPURL,
"status": gitCredentialDisplayStatus(record.Status),
})
}
return out
}
func gitCredentialDisplayStatus(status string) string {
if status == gitcred.ListStatusExpired {
return "refresh_required"
}
return status
}
func profileFromConfig(cfg *core.CliConfig) gitcred.ProfileContext {
if cfg == nil {
return gitcred.ProfileContext{}
}
return gitcred.ProfileContext{
Profile: cfg.ProfileName,
ProfileAppID: cfg.AppID,
UserOpenID: cfg.UserOpenId,
}
}
func issuePath(appID string) string {
return strings.Replace(gitCredentialIssuePath, ":app_id", url.PathEscape(strings.TrimSpace(appID)), 1)
}
func gitCredentialIssueParams(appID string) map[string]interface{} {
return map[string]interface{}{"app_id": strings.TrimSpace(appID)}
}
func initStatus(result *gitcred.InitResult) string {
if result != nil && result.Refreshed {
return "refreshed"
}
return "initialized"
}
func gitCredentialLocalError(action string, err error) error {
if err == nil {
return nil
}
if _, ok := errs.UnwrapTypedError(err); ok {
return err
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return &errs.ConfigError{Problem: errs.Problem{
Category: errs.CategoryConfig,
Subtype: errs.SubtypeInvalidConfig,
Message: fmt.Sprintf("%s: %s", action, err),
Hint: "retry the command; if the local Git credential state is damaged, rerun `lark-cli apps +git-credential-init --app-id <app_id>` or remove the app credential again",
}, Cause: err}
}
func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedCredential, error) {
source := data
for _, key := range []string{"credential", "git_credential", "gitInfo", "git_info"} {
if nested, ok := data[key].(map[string]interface{}); ok {
source = nested
break
}
}
issued := &gitcred.IssuedCredential{
AppID: firstString(source, "app_id", appID),
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
Username: firstString(source, "username"),
PAT: firstString(source, "token", "Token", "pat", "password"),
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
}
if issued.AppID == "" {
issued.AppID = appID
}
if issued.GitHTTPURL == "" {
return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL")
}
if issued.PAT == "" {
return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token")
}
return issued, nil
}
func parseIssueCredentialData(resp *larkcore.ApiResp, err error) (map[string]any, error) {
if err != nil {
return nil, err
}
detail := logIDDetail(resp)
if resp == nil || len(resp.RawBody) == 0 {
return nil, &errs.InternalError{Problem: errs.Problem{
Category: errs.CategoryInternal,
Subtype: errs.SubtypeUnknown,
Message: "Issue Miaoda Git credential: empty response body",
}}
}
var result map[string]any
if jsonErr := json.Unmarshal(resp.RawBody, &result); jsonErr != nil {
return nil, &errs.InternalError{Problem: errs.Problem{
Category: errs.CategoryInternal,
Subtype: errs.SubtypeUnknown,
Message: fmt.Sprintf("Issue Miaoda Git credential: unmarshal response: %s", jsonErr),
}, Cause: jsonErr}
}
if resp.StatusCode >= http.StatusBadRequest {
msg := firstString(result, "msg", "message")
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return nil, &errs.APIError{Problem: errs.Problem{
Category: errs.CategoryAPI,
Subtype: errs.SubtypeUnknown,
Code: resp.StatusCode,
Message: msg,
LogID: logIDString(resp),
Hint: gitCredentialIssueHint,
Retryable: resp.StatusCode >= http.StatusInternalServerError,
}}
}
if _, hasCode := result["code"]; hasCode {
code := firstInt64(result, "code")
if code != 0 {
return nil, &errs.APIError{Problem: errs.Problem{
Category: errs.CategoryAPI,
Subtype: errs.SubtypeUnknown,
Code: int(code),
Message: firstString(result, "msg", "message"),
LogID: logIDString(resp),
Hint: gitCredentialIssueHint,
}}
}
if data, ok := result["data"].(map[string]any); ok {
result = data
}
} else if err := checkGitInfoBaseResp(result, logIDString(resp)); err != nil {
return nil, err
}
if detail != nil {
if result == nil {
result = map[string]any{}
}
for k, v := range detail {
result[k] = v
}
}
return result, nil
}
func checkGitInfoBaseResp(result map[string]any, logID string) error {
for _, key := range []string{"BaseResp", "baseResp", "base_resp"} {
baseResp, ok := result[key].(map[string]any)
if !ok {
continue
}
code := firstInt64(baseResp, "StatusCode", "statusCode", "status_code")
if code == 0 {
return nil
}
message := firstString(baseResp, "StatusMessage", "statusMessage", "status_message")
if message == "" {
message = "Git credential API returned non-zero BaseResp status"
}
return &errs.APIError{Problem: errs.Problem{
Category: errs.CategoryAPI,
Subtype: errs.SubtypeUnknown,
Code: int(code),
Message: "Issue Miaoda Git credential: " + message,
LogID: logID,
}}
}
return nil
}
func logIDDetail(resp *larkcore.ApiResp) map[string]any {
logID := logIDString(resp)
if logID == "" {
return nil
}
return map[string]any{"log_id": logID}
}
func logIDString(resp *larkcore.ApiResp) string {
if resp == nil {
return ""
}
return resp.Header.Get("x-tt-logid")
}
func firstString(data map[string]interface{}, keys ...string) string {
for _, key := range keys {
if v, ok := data[key].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func firstInt64(data map[string]interface{}, keys ...string) int64 {
for _, key := range keys {
switch v := data[key].(type) {
case int64:
return v
case int:
return int64(v)
case float64:
return int64(v)
case string:
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
return n
}
}
return 0
}

View File

@@ -1,55 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // Git credential list scans CLI config-dir state; it is not user file I/O.
)
type gitCredentialAppStorage struct{}
func (gitCredentialAppStorage) Read(appID, key string) ([]byte, error) {
return Read(appID, key)
}
func (gitCredentialAppStorage) Write(appID, key string, data []byte) error {
return Write(appID, key, data)
}
func (gitCredentialAppStorage) Delete(appID, key string) error {
return Delete(appID, key)
}
func (gitCredentialAppStorage) ListAppIDs() ([]string, error) {
root := filepath.Join(core.GetConfigDir(), storageRoot)
entries, err := vfs.ReadDir(root)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []string{}, nil
}
return nil, fmt.Errorf("apps storage: read root: %w", err)
}
appIDs := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
continue
}
appID, err := url.PathUnescape(e.Name())
if err != nil {
continue
}
if err := checkSeg(appID, "appID"); err != nil {
continue
}
appIDs = append(appIDs, appID)
}
return appIDs, nil
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More