mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
25 Commits
fix/ppe-re
...
v1.0.51
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f2e049858 | ||
|
|
2c703f2fce | ||
|
|
501bf539af | ||
|
|
8e667db534 | ||
|
|
e751a53f76 | ||
|
|
e794fd5925 | ||
|
|
077b5e7180 | ||
|
|
0d20a02050 | ||
|
|
7cc0b49603 | ||
|
|
6b48a39d55 | ||
|
|
b07be60068 | ||
|
|
31bc87a2cc | ||
|
|
7fdf55821b | ||
|
|
201e3e016f | ||
|
|
eed711bb11 | ||
|
|
4f4c0b59c9 | ||
|
|
2b4c6349a1 | ||
|
|
944cd55fc7 | ||
|
|
7229baae40 | ||
|
|
170565c57e | ||
|
|
03ea6e78b8 | ||
|
|
ed3fe9337f | ||
|
|
cc416a4de5 | ||
|
|
00d45f8fa2 | ||
|
|
0d847511d2 |
@@ -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/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||
- 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/|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/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
|
||||
- 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/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/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||
- 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/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
|
||||
|
||||
Picking a constructor:
|
||||
|
||||
| Failure | Constructor |
|
||||
|---------|-------------|
|
||||
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
|
||||
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
|
||||
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
|
||||
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
|
||||
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
|
||||
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
|
||||
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
|
||||
|
||||
Signatures that are easy to guess wrong:
|
||||
|
||||
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
|
||||
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }` — `ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
|
||||
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
|
||||
|
||||
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
|
||||
|
||||
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
|
||||
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
|
||||
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
|
||||
83
CHANGELOG.md
83
CHANGELOG.md
@@ -2,6 +2,86 @@
|
||||
|
||||
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
|
||||
|
||||
- **events**: Add whiteboard event domain with per-board subscription (#1265)
|
||||
- **im**: Support feed group (#1102)
|
||||
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
|
||||
- **im**: Format feed group error handling (#1308)
|
||||
- **im**: Return typed error envelopes across the im domain (#1230)
|
||||
- **base**: Emit typed error envelopes across the base domain (#1248)
|
||||
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
|
||||
- **task**: Emit typed error envelopes across the task domain (#1231)
|
||||
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
|
||||
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
|
||||
- **markdown**: Harden create upload failures (#1325)
|
||||
- **drive**: Harden inspect shortcut failures (#1324)
|
||||
- **slides**: Add IconPark lookup for Lark slides (#1123)
|
||||
- **doc**: Remove docs v1 API (#1291)
|
||||
- **cli**: Add `skills` command to read embedded skill content (#1318)
|
||||
- **cli**: Fetch official skills index (#1301)
|
||||
- **shared**: Document relative-path-only file arguments (#1319)
|
||||
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
|
||||
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
|
||||
- **drive**: Use docs secure label read scope (#1281)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
|
||||
- **skills**: Tighten drive and markdown guardrails (#1326)
|
||||
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
|
||||
- **markdown**: Add markdown domain template (#1293)
|
||||
- **markdown**: Improve lark-markdown skill guidance (#1279)
|
||||
- **doc**: Improve lark-doc skill guidance (#1283)
|
||||
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
|
||||
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
|
||||
|
||||
## [v1.0.48] - 2026-06-04
|
||||
|
||||
### Features
|
||||
@@ -1026,6 +1106,9 @@ 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
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
|
||||
@@ -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 | Develop, deploy HTML, web pages and applications |
|
||||
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
||||
| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -296,10 +296,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
|
||||
// capture stdout into a JSON parser see it without stream-mixing surprises.
|
||||
// Text mode prints the hint to stderr only when running under a non-TTY
|
||||
// (i.e. piped / agent harness), since humans reading a terminal don't need
|
||||
// the agent-oriented instructions.
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -317,7 +318,9 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -404,10 +407,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
||||
// device_code already returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
// Skip the stderr hint in JSON mode (the --no-wait call that issued
|
||||
// the device_code already surfaced it as a JSON field), and also skip it
|
||||
// when running on an interactive terminal — the agent-oriented
|
||||
// instructions only matter for piped / harness environments.
|
||||
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"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/event"
|
||||
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"set up bus logger: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}()
|
||||
|
||||
return b.Run(ctx)
|
||||
if err := b.Run(ctx); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus daemon exited: %s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
45
cmd/event/bus_test.go
Normal file
45
cmd/event/bus_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||
// when its log directory cannot be created (the error is only visible in the
|
||||
// forked process's captured stderr / bus.log).
|
||||
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Block the events/ root with a regular file so MkdirAll fails.
|
||||
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdBus(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected logger setup error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryInternal, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--jq").
|
||||
WithCause(err).
|
||||
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,12 +261,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
@@ -301,23 +301,27 @@ func preflightEventTypes(pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID))
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirUnsafe)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
@@ -329,18 +333,21 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"resolve tenant access token: %s", err).WithCause(err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no tenant access token available for app %s", appID).
|
||||
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
@@ -352,7 +359,10 @@ func parseParams(raw []string) (map[string]string, error) {
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||
WithParam("--param").
|
||||
WithCause(errInvalidParamFormat)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
@@ -73,6 +78,7 @@ func TestParseParams(t *testing.T) {
|
||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--param")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -90,6 +96,77 @@ func TestParseParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// emptyTokenResolver resolves to a result that carries no token.
|
||||
type emptyTokenResolver struct{}
|
||||
|
||||
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{}, nil
|
||||
}
|
||||
|
||||
// failingTokenResolver fails outright with an untyped error.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, errors.New("backend unavailable")
|
||||
}
|
||||
|
||||
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
var malformed *credential.MalformedTokenResultError
|
||||
if !errors.As(err, &malformed) {
|
||||
t.Error("empty-token failure should preserve the credential-layer cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("resolver failure should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
// assertInvalidArgumentParam verifies err is a typed validation error with
|
||||
// subtype invalid_argument naming the given flag in its param field.
|
||||
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != param {
|
||||
t.Errorf("param = %q, want %q", ve.Param, param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -130,6 +207,7 @@ func TestSanitizeOutputDir(t *testing.T) {
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--output-dir")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
@@ -89,19 +89,17 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint")
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,17 +143,19 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
|
||||
errs.CategoryAuthorization, errs.SubtypeMissingScope)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
wantMissing := []string{"im:message.group_at_msg"}
|
||||
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
|
||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
|
||||
}
|
||||
hint := exit.Detail.Hint
|
||||
hint := permErr.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
|
||||
@@ -6,8 +6,8 @@ package event
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -26,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
|
||||
147
cmd/event/runtime_test.go
Normal file
147
cmd/event/runtime_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
// stubRoundTripper intercepts every outgoing request with a canned response.
|
||||
type stubRoundTripper struct {
|
||||
respond func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
|
||||
|
||||
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
return &consumeRuntime{
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
|
||||
return func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{contentType}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
if !strings.Contains(err.Error(), "returned 404") {
|
||||
t.Errorf("error should echo the HTTP status, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
|
||||
long := strings.Repeat("x", 300)
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if !p.Retryable {
|
||||
t.Fatal("5xx non-JSON response should be marked retryable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "…(truncated)") {
|
||||
t.Errorf("long body should be truncated in the message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":99991663,"msg":"app not found"}`)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":0,"data":{"ok":true}}`)})
|
||||
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) {
|
||||
t.Errorf("raw body should pass through, got: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
@@ -39,12 +40,14 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -165,7 +168,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
return err
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
@@ -129,3 +130,38 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
|
||||
_, err := renderSpec(&eventlib.SchemaSpec{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for spec with neither Type nor Raw")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "synthetic.invalid.base",
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
|
||||
},
|
||||
}
|
||||
_, _, err := resolveSchemaJSON(def)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unparsable base schema")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
@@ -64,9 +64,6 @@ func unknownEventKeyErr(key string) error {
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,8 @@ const cleanupTimeout = 5 * time.Second
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
35
events/vc/note_detail_retry_test.go
Normal file
35
events/vc/note_detail_retry_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// isLarkCode must match the API code on typed errs.* errors — the consume
|
||||
// runtime classifies OAPI failures via errclass.BuildAPIError, so the
|
||||
// not-found retry in fillVCNoteGeneratedDetails depends on this reading
|
||||
// Problem.Code rather than the legacy envelope shape.
|
||||
func TestIsLarkCode_MatchesTypedAPIErrorCode(t *testing.T) {
|
||||
typedNotFound := errs.NewAPIError(errs.SubtypeNotFound, "note not ready").
|
||||
WithCode(vcNoteDetailNotFoundCode)
|
||||
if !isLarkCode(typedNotFound, vcNoteDetailNotFoundCode) {
|
||||
t.Fatal("typed API error carrying the not-found code must match (retry path)")
|
||||
}
|
||||
if isLarkCode(typedNotFound, 99999) {
|
||||
t.Error("a different expected code must not match")
|
||||
}
|
||||
|
||||
otherTyped := errs.NewAPIError(errs.SubtypeServerError, "boom").WithCode(500)
|
||||
if isLarkCode(otherTyped, vcNoteDetailNotFoundCode) {
|
||||
t.Error("typed error with another code must not match")
|
||||
}
|
||||
|
||||
if isLarkCode(errors.New("plain failure"), vcNoteDetailNotFoundCode) {
|
||||
t.Error("untyped error must not match")
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,11 @@ package vc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -148,9 +147,8 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
|
||||
}
|
||||
|
||||
func isLarkCode(err error, code int) bool {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return exitErr.Detail.Code == code
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
return p.Code == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,8 @@ const cleanupTimeout = 5 * time.Second
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
84
events/vc/recording_ended.go
Normal file
84
events/vc/recording_ended.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// 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)
|
||||
}
|
||||
84
events/vc/recording_started.go
Normal file
84
events/vc/recording_started.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// 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)
|
||||
}
|
||||
468
events/vc/recording_test.go
Normal file
468
events/vc/recording_test.go
Normal file
@@ -0,0 +1,468 @@
|
||||
// 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)
|
||||
}
|
||||
163
events/vc/recording_transcript_generated.go
Normal file
163
events/vc/recording_transcript_generated.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -11,13 +11,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
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"
|
||||
|
||||
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"
|
||||
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"
|
||||
|
||||
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
|
||||
)
|
||||
@@ -57,5 +62,53 @@ 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},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
@@ -24,11 +25,15 @@ const cleanupTimeout = 5 * time.Second
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
whiteboardID := params["whiteboard_id"]
|
||||
if whiteboardID == "" {
|
||||
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"param whiteboard_id is required for %s", eventType).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param whiteboard_id=<id>; run `lark-cli event schema %s` for details", eventType)
|
||||
}
|
||||
encoded := validate.EncodePathSegment(whiteboardID)
|
||||
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -58,6 +59,16 @@ func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "whiteboard_id") {
|
||||
t.Fatalf("error should mention whiteboard_id, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("missing whiteboard_id should carry a hint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
|
||||
@@ -70,6 +81,9 @@ func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when runtime client is nil")
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryInternal {
|
||||
t.Errorf("nil-runtime invariant should be a typed internal error, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
|
||||
|
||||
@@ -153,79 +153,9 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
|
||||
if err != nil {
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
c.logAPIResponse(req, resp)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *APIClient) logAPIResponse(req *larkcore.ApiReq, resp *larkcore.ApiResp) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
logID := strings.TrimSpace(resp.LogId())
|
||||
if logID == "" {
|
||||
return
|
||||
}
|
||||
method, path := apiReqLogFields(req, "")
|
||||
fmt.Fprintf(c.errOut(), "[lark-cli] api-response: method=%s path=%s status=%d log_id=%s\n", method, path, resp.StatusCode, logID)
|
||||
}
|
||||
|
||||
func (c *APIClient) logStreamResponse(req *larkcore.ApiReq, requestURL string, resp *http.Response) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
logID := streamLogID(resp.Header)
|
||||
if logID == "" {
|
||||
return
|
||||
}
|
||||
method, path := apiReqLogFields(req, requestURL)
|
||||
fmt.Fprintf(c.errOut(), "[lark-cli] api-response: method=%s path=%s status=%d log_id=%s\n", method, path, resp.StatusCode, logID)
|
||||
}
|
||||
|
||||
func (c *APIClient) errOut() io.Writer {
|
||||
if c != nil && c.ErrOut != nil {
|
||||
return c.ErrOut
|
||||
}
|
||||
return io.Discard
|
||||
}
|
||||
|
||||
func apiReqLogFields(req *larkcore.ApiReq, fallbackURL string) (string, string) {
|
||||
method := ""
|
||||
path := ""
|
||||
if req != nil {
|
||||
method = req.HttpMethod
|
||||
path = req.ApiPath
|
||||
}
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
if method == "" {
|
||||
method = "UNKNOWN"
|
||||
}
|
||||
path = requestLogPath(path)
|
||||
if path == "missing" {
|
||||
path = requestLogPath(fallbackURL)
|
||||
}
|
||||
return method, path
|
||||
}
|
||||
|
||||
func requestLogPath(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "missing"
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil && u.IsAbs() {
|
||||
if u.EscapedPath() != "" {
|
||||
return u.EscapedPath()
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
if i := strings.Index(raw, "?"); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
if raw == "" {
|
||||
return "missing"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
|
||||
// Unlike DoSDKRequest (which buffers the full body via the SDK), DoStream returns
|
||||
// a live *http.Response whose Body is an io.Reader for streaming consumption.
|
||||
@@ -294,7 +224,6 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
|
||||
}
|
||||
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
|
||||
c.logStreamResponse(req, requestURL, resp)
|
||||
|
||||
// Handle HTTP errors internally
|
||||
if resp.StatusCode >= 400 {
|
||||
|
||||
@@ -464,48 +464,6 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoStream_LogsLogIDToErrOut(t *testing.T) {
|
||||
errBuf := &bytes.Buffer{}
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
larkcore.HttpHeaderKeyLogId: []string{"stream-log-123"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader("ok")),
|
||||
}, nil
|
||||
})
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Transport: rt},
|
||||
ErrOut: errBuf,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
resp, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
|
||||
}, core.AsBot)
|
||||
if err != nil {
|
||||
t.Fatalf("DoStream() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := errBuf.String()
|
||||
for _, want := range []string{
|
||||
"[lark-cli] api-response:",
|
||||
"method=GET",
|
||||
"path=/open-apis/drive/v1/medias/file_token/download",
|
||||
"status=200",
|
||||
"log_id=stream-log-123",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("log missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// failingTokenResolver always returns TokenUnavailableError, exercising the
|
||||
// auth/credential failure path through resolveAccessToken.
|
||||
type failingTokenResolver struct{}
|
||||
@@ -660,41 +618,6 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoSDKRequest_LogsLogIDToErrOut(t *testing.T) {
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
larkcore.HttpHeaderKeyLogId: []string{"sdk-log-123"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":0,"msg":"ok","data":{}}`)),
|
||||
}, nil
|
||||
})
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/contact/v3/users/me",
|
||||
}, core.AsBot)
|
||||
if err != nil {
|
||||
t.Fatalf("DoSDKRequest() error = %v", err)
|
||||
}
|
||||
|
||||
got := errBuf.String()
|
||||
for _, want := range []string{
|
||||
"[lark-cli] api-response:",
|
||||
"method=GET",
|
||||
"path=/open-apis/contact/v3/users/me",
|
||||
"status=200",
|
||||
"log_id=sdk-log-123",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("log missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the typed-envelope contract for
|
||||
// malformed JSON response bodies: WrapJSONResponseParseError emits
|
||||
// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint
|
||||
|
||||
@@ -28,14 +28,8 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderTTEnv = "X-Tt-Env"
|
||||
HeaderUsePPE = "X-Use-Ppe"
|
||||
HeaderRPCAppID = "Rpc-Persist-Cli-Req-App-Id"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
TTEnvValue = "ppe_doubao_office_local"
|
||||
UsePPEValue = "1"
|
||||
RPCAppID = "497858"
|
||||
|
||||
HeaderUserAgent = "User-Agent"
|
||||
|
||||
@@ -81,9 +75,6 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
h.Set(HeaderTTEnv, TTEnvValue)
|
||||
h.Set(HeaderUsePPE, UsePPEValue)
|
||||
h.Set(HeaderRPCAppID, RPCAppID)
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
|
||||
@@ -256,26 +256,13 @@ func TestBaseSecurityHeaders_IncludesBuildHeader(t *testing.T) {
|
||||
|
||||
func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
h := BaseSecurityHeaders()
|
||||
for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent, HeaderTTEnv, HeaderUsePPE, HeaderRPCAppID} {
|
||||
for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent} {
|
||||
if h.Get(key) == "" {
|
||||
t.Errorf("BaseSecurityHeaders missing %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesPersistentRequestHeaders(t *testing.T) {
|
||||
h := BaseSecurityHeaders()
|
||||
if got := h.Get(HeaderTTEnv); got != TTEnvValue {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderTTEnv, got, TTEnvValue)
|
||||
}
|
||||
if got := h.Get(HeaderUsePPE); got != UsePPEValue {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderUsePPE, got, UsePPEValue)
|
||||
}
|
||||
if got := h.Get(HeaderRPCAppID); got != RPCAppID {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderRPCAppID, got, RPCAppID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentTraceValue / HeaderAgentTrace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
@@ -44,7 +45,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
keyDef, ok := event.Lookup(opts.EventKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown EventKey: %s", opts.EventKey).
|
||||
WithHint("run `lark-cli event list` to see available keys")
|
||||
}
|
||||
|
||||
if err := validateParams(keyDef, opts.Params); err != nil {
|
||||
@@ -80,7 +83,8 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
|
||||
if err != nil {
|
||||
return fmt.Errorf("handshake failed: %w", err)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
@@ -90,7 +94,11 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pre-consume failed: %w", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pre-consume failed: %s", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +160,10 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
for _, p := range def.Params {
|
||||
if p.Required {
|
||||
if _, ok := params[p.Name]; !ok {
|
||||
return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
|
||||
p.Name, def.Key, def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"required param %q missing for EventKey %s", p.Name, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param %s=<value>; run `lark-cli event schema %s` for details", p.Name, def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,11 +179,15 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
continue
|
||||
}
|
||||
if len(validNames) == 0 {
|
||||
return fmt.Errorf("unknown param %q: EventKey %s accepts no params. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q: EventKey %s accepts no params", k, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
}
|
||||
return fmt.Errorf("unknown param %q for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, strings.Join(validNames, ", "), def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,17 +8,21 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
|
||||
func CompileJQ(expr string) (*gojq.Code, error) {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid jq expression: %w", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jq compile error: %w", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
@@ -20,6 +23,16 @@ func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
|
||||
t.Errorf("error should mention compile/parse/invalid, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--jq" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--jq")
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("compile error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileJQReturnsUsableCode(t *testing.T) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -23,7 +24,8 @@ type Sink interface {
|
||||
func newSink(opts Options) (Sink, error) {
|
||||
if opts.OutputDir != "" {
|
||||
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create output dir: %w", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"create output dir: %s", err).WithCause(err)
|
||||
}
|
||||
// PID disambiguates filenames across processes sharing a Dir.
|
||||
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
@@ -51,10 +52,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
|
||||
if count > 0 {
|
||||
return nil, fmt.Errorf("another event bus is already connected to this app "+
|
||||
"(%d active connection(s) detected via API).\n"+
|
||||
"Only one bus should run globally to avoid duplicate event delivery.\n"+
|
||||
"Use 'lark-cli event status' to check, or 'lark-cli event stop' on the other machine first", count)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -65,8 +65,10 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
pid, forkErr := forkBus(tr, appID, profileName, domain)
|
||||
if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) {
|
||||
eventsRoot := filepath.Join(core.GetConfigDir(), "events")
|
||||
return nil, fmt.Errorf("failed to start event bus daemon: %w\n"+
|
||||
"Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to start event bus daemon: %s", forkErr).
|
||||
WithCause(forkErr).
|
||||
WithHint("check disk space, permissions on %s, and `lark-cli doctor`", eventsRoot)
|
||||
}
|
||||
if pid > 0 {
|
||||
announceForkedBus(errOut, pid)
|
||||
@@ -88,7 +90,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.")
|
||||
fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.")
|
||||
fmt.Fprintf(errOut, "[event] logs: %s\n", logPath)
|
||||
return nil, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to connect to event bus within %v (app=%s)", dialTimeout, appID).
|
||||
WithHint("check app credentials (`lark-cli config show`) and retry; bus logs: %s", logPath)
|
||||
}
|
||||
|
||||
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.
|
||||
|
||||
99
internal/event/consume/startup_guard_test.go
Normal file
99
internal/event/consume/startup_guard_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// failDialTransport refuses every dial so EnsureBus falls through to the
|
||||
// remote-connection check without a local bus.
|
||||
type failDialTransport struct{}
|
||||
|
||||
func (failDialTransport) Listen(string) (net.Listener, error) { return nil, errors.New("no listen") }
|
||||
func (failDialTransport) Dial(string) (net.Conn, error) { return nil, errors.New("refused") }
|
||||
func (failDialTransport) Address(string) string { return "guard-test-addr" }
|
||||
func (failDialTransport) Cleanup(string) {}
|
||||
|
||||
// remoteBusyAPIClient reports active remote WebSocket connections.
|
||||
type remoteBusyAPIClient struct{ count int }
|
||||
|
||||
func (c remoteBusyAPIClient) CallAPI(context.Context, string, string, interface{}) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"code":0,"msg":"ok","data":{"online_instance_cnt":` +
|
||||
strconv.Itoa(c.count) + `}}`), nil
|
||||
}
|
||||
|
||||
func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
|
||||
conn, err := EnsureBus(context.Background(), failDialTransport{},
|
||||
"cli_guard_test", "", "", remoteBusyAPIClient{count: 2}, io.Discard)
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn when a remote bus is already connected")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected single-bus guard error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event stop") {
|
||||
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) {
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "bogus.run.key",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown EventKey error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event list") {
|
||||
t.Errorf("hint should point at `event list`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) {
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "consume.runtest.jq",
|
||||
EventType: "consume.runtest.jq_v1",
|
||||
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}},
|
||||
})
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "consume.runtest.jq",
|
||||
JQExpr: "[invalid{{{",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected jq validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--jq" {
|
||||
t.Errorf("param = %q, want %q", ve.Param, "--jq")
|
||||
}
|
||||
}
|
||||
64
internal/event/consume/validate_params_test.go
Normal file
64
internal/event/consume/validate_params_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func requireParamValidationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("param validation error should hint at `lark-cli event schema`")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParams_RequiredMissing(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id", Required: true}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParam(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id"}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParamNoParamsAccepted(t *testing.T) {
|
||||
def := &event.KeyDefinition{Key: "x.test"}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_DefaultAppliedAndValidPasses(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "mode", Required: true, Default: "all"}},
|
||||
}
|
||||
params := map[string]string{}
|
||||
if err := validateParams(def, params); err != nil {
|
||||
t.Fatalf("default should satisfy required param, got: %v", err)
|
||||
}
|
||||
if params["mode"] != "all" {
|
||||
t.Errorf("default not applied, params=%v", params)
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,21 @@ import (
|
||||
// legacy validation/save helpers are forbidden; callers must use the typed
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"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/",
|
||||
|
||||
@@ -16,12 +16,21 @@ import (
|
||||
// call sites must return a typed errs.* error instead. Future domains opt in by
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"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/",
|
||||
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
|
||||
// they return the raw response for the caller to classify and do not emit a
|
||||
// legacy envelope themselves.
|
||||
//
|
||||
// Files that do not import shortcuts/common are skipped: the legacy helpers
|
||||
// are methods on common.RuntimeContext, so a same-named method on another
|
||||
// receiver (for example the event domain's APIClient interface, whose
|
||||
// implementation classifies into typed errs.* errors) is not a legacy call.
|
||||
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
@@ -36,6 +41,9 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !importsPath(file, commonImportPath) {
|
||||
return nil
|
||||
}
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
@@ -71,3 +79,16 @@ func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// importsPath reports whether the file imports the given package path.
|
||||
func importsPath(file *ast.File, importPath string) bool {
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
if strings.Trim(imp.Path.Value, "`\"") == importPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -620,6 +620,7 @@ 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",
|
||||
@@ -691,7 +692,7 @@ func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
@@ -813,6 +814,8 @@ func boom() error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -833,6 +836,8 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
|
||||
src := `package task
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -853,6 +858,8 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -907,7 +914,7 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
@@ -944,9 +951,13 @@ 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",
|
||||
}
|
||||
@@ -997,6 +1008,74 @@ 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_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package contact
|
||||
|
||||
@@ -1006,7 +1085,7 @@ func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
@@ -1076,3 +1155,23 @@ func boom() error {
|
||||
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_SkipsNonCommonReceiver(t *testing.T) {
|
||||
// The event domain's APIClient interface has a same-named CallAPI method
|
||||
// whose implementation classifies into typed errs.* errors; without the
|
||||
// shortcuts/common import the call cannot be the legacy RuntimeContext
|
||||
// helper and must not fire.
|
||||
src := `package vc
|
||||
|
||||
import "github.com/larksuite/cli/internal/event"
|
||||
|
||||
func boom(rt event.APIClient) error {
|
||||
_, err := rt.CallAPI(nil, "POST", "/x", nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("events/vc/preconsume.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-common CallAPI receiver must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.48",
|
||||
"version": "1.0.51",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -21,9 +21,12 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
Command: "+access-scope-get",
|
||||
Description: "Get Miaoda app access scope configuration",
|
||||
Risk: "read",
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +access-scope-get --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
},
|
||||
@@ -42,9 +45,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.CallAPI("GET", path, nil, nil)
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||
}
|
||||
// 原样透传 — 保留服务端字符串枚举 (All/Tenant/Range),不合并 users/departments/chats。
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
|
||||
@@ -27,9 +27,14 @@ var AppsAccessScopeSet = common.Shortcut{
|
||||
Command: "+access-scope-set",
|
||||
Description: "Set Miaoda app access scope (specific / public / tenant)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
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,
|
||||
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"}},
|
||||
@@ -64,9 +69,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.CallAPI("PUT", path, nil, body)
|
||||
data, err := rctx.CallAPITyped("PUT", path, nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
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>`")
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "access-scope set: %s\n", rctx.Str("scope"))
|
||||
|
||||
@@ -8,9 +8,62 @@ 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{
|
||||
@@ -201,3 +254,44 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
71
shortcuts/apps/apps_callapi_typed_test.go
Normal file
71
shortcuts/apps/apps_callapi_typed_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
83
shortcuts/apps/apps_chat.go
Normal file
83
shortcuts/apps/apps_chat.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// 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")),
|
||||
}
|
||||
}
|
||||
104
shortcuts/apps/apps_chat_test.go
Normal file
104
shortcuts/apps/apps_chat_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,24 @@ 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",
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
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,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "app display name", Required: true},
|
||||
{Name: "app-type", Desc: "app type (currently only: HTML)", Required: true},
|
||||
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}},
|
||||
{Name: "description", Desc: "app description"},
|
||||
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
|
||||
},
|
||||
@@ -32,13 +38,6 @@ 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 {
|
||||
@@ -48,9 +47,9 @@ var AppsCreate = common.Shortcut{
|
||||
Body(buildAppsCreateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPI("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
|
||||
data, err := rctx.CallAPITyped("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
return withAppsHint(err, createHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "created: %s\n", common.GetString(data, "app", "app_id"))
|
||||
@@ -59,15 +58,13 @@ 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": strings.TrimSpace(rctx.Str("app-type")),
|
||||
"app_type": rctx.Str("app-type"),
|
||||
}
|
||||
if desc := strings.TrimSpace(rctx.Str("description")); desc != "" {
|
||||
body["description"] = desc
|
||||
|
||||
@@ -22,6 +22,7 @@ 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()),
|
||||
@@ -68,7 +69,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)
|
||||
}
|
||||
@@ -83,8 +84,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"])
|
||||
@@ -108,7 +109,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)
|
||||
}
|
||||
@@ -133,7 +134,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)
|
||||
}
|
||||
@@ -144,7 +145,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)
|
||||
}
|
||||
@@ -159,20 +160,31 @@ 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) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -183,7 +195,55 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
98
shortcuts/apps/apps_db_env_create.go
Normal file
98
shortcuts/apps/apps_db_env_create.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// 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 环境创建 body:sync_data(bool)。
|
||||
// --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.")
|
||||
}
|
||||
124
shortcuts/apps/apps_db_env_create_test.go
Normal file
124
shortcuts/apps/apps_db_env_create_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// 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_init,CLI 命令名 +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)
|
||||
}
|
||||
}
|
||||
520
shortcuts/apps/apps_db_execute.go
Normal file
520
shortcuts/apps/apps_db_execute.go
Normal file
@@ -0,0 +1,520 @@
|
||||
// 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/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_commands,CLI 永远带 ?transactional=false 进入 DBA 模式
|
||||
// (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON)。
|
||||
//
|
||||
// pretty 渲染 6 种形态:
|
||||
// - 单 SELECT:表格(列间两空格、列对齐填充)
|
||||
// - 空 SELECT:`(0 rows)`
|
||||
// - 单 DML:`✓ N row(s) <verb>`(verb 跟 sql_type:INSERT→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 api_error(exit 非 0、detail 带 statement_index / completed / rolled_back),
|
||||
// 避免 agent 误判 ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
|
||||
// 落地,故 rolled_back=false(真机 boe 实证)。
|
||||
//
|
||||
// JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
|
||||
//
|
||||
// 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: filter fields with --jq, e.g. -q '.data.results[].sql_type'",
|
||||
},
|
||||
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 解出来放进 envelope 的 data.results,
|
||||
// 让 json/pretty 路径都基于同一份反序列化产物渲染。
|
||||
stmts := parseSQLResult(common.GetString(raw, "result"))
|
||||
// 注意:data.results 在 json(默认)路径下原样透出全部行,CLI 侧不再二次截断。
|
||||
// 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出会直接
|
||||
// 返报错(而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。
|
||||
data := map[string]interface{}{"results": stmts}
|
||||
|
||||
// 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。
|
||||
// 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→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
|
||||
},
|
||||
}
|
||||
|
||||
// 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 api_error。
|
||||
//
|
||||
// CLI 永远 DBA 模式(transactional=false),真机 boe 实证:失败语句之前的语句已逐条 auto-commit
|
||||
// 落地,不存在外层事务回滚。因此 rolled_back=false、completed 列出已落地的前序语句,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))
|
||||
|
||||
apiErr := output.ErrAPI(code, fullMsg, map[string]interface{}{
|
||||
"statement_index": errIdx,
|
||||
"completed": stmts[:errIdx],
|
||||
"rolled_back": false,
|
||||
})
|
||||
if apiErr.Detail != nil {
|
||||
if errIdx > 0 {
|
||||
apiErr.Detail.Hint = fmt.Sprintf(
|
||||
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
|
||||
errIdx, stmtNo)
|
||||
} else {
|
||||
apiErr.Detail.Hint = "no statements were applied; fix the SQL and re-run."
|
||||
}
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
// 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 接口的 query:env + 强制 transactional=false(DBA 模式)。
|
||||
//
|
||||
// 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=OK(DML/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 error(exit 非 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 永远 DBA 模式(transactional=false),失败语句之前的语句已 auto-commit 落地,
|
||||
// 不存在整批回滚 —— 如实告诉用户,避免整批重跑导致重复写入。
|
||||
if successCount > 0 {
|
||||
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it already applied — DBA mode auto-commits each)\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 的 data(rows 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 wire:SELECT 走表格、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
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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 转 int64;nil / 类型不匹配返回 0。
|
||||
func intOrZero(raw interface{}) int64 {
|
||||
if n, ok := numericAsFloat(raw); ok {
|
||||
return int64(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
797
shortcuts/apps/apps_db_execute_test.go
Normal file
797
shortcuts/apps/apps_db_execute_test.go
Normal file
@@ -0,0 +1,797 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(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)
|
||||
}
|
||||
// JSON envelope 应该把 result 字符串 parse 之后放进 data.results
|
||||
var env struct {
|
||||
Data struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `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.Results) != 1 {
|
||||
t.Fatalf("data.results = %d items (want 1)", len(env.Data.Results))
|
||||
}
|
||||
if env.Data.Results[0]["sql_type"] != "SELECT" {
|
||||
t.Fatalf("results[0].sql_type = %v", env.Data.Results[0]["sql_type"])
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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.sql(dry-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)。
|
||||
// ============================================================================
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) {
|
||||
// 验证 JSON envelope 也把 legacy result 正确归一化进 data.results
|
||||
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 struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data.Results) != 1 {
|
||||
t.Fatalf("results length = %d, want 1; got: %v", len(env.Data.Results), env.Data.Results)
|
||||
}
|
||||
if env.Data.Results[0]["sql_type"] != "SELECT" {
|
||||
t.Fatalf("results[0].sql_type = %v, want SELECT", env.Data.Results[0]["sql_type"])
|
||||
}
|
||||
if env.Data.Results[0]["record_count"] != float64(1) {
|
||||
t.Fatalf("results[0].record_count = %v, want 1", env.Data.Results[0]["record_count"])
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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_TABLE(data="[]"、无 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 error(exit 非 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)
|
||||
}
|
||||
}
|
||||
// DBA 模式(transactional=false)前序语句已 auto-commit 落地,绝不能误报「rolled back」。
|
||||
if strings.Contains(got, "rolled back") {
|
||||
t.Errorf("DBA mode must NOT claim rollback (prior statements persisted); 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 api_error」:
|
||||
// json 默认不再打 ok:true 假成功,而是返回 *output.ExitError(type=api_error、非零 exit),
|
||||
// detail 带 statement_index / completed / rolled_back。rolled_back=false 因 CLI 永远 DBA 模式
|
||||
// (真机 boe 实证:失败前的语句已落地)。
|
||||
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())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "api_error" {
|
||||
t.Errorf("error.type = %q, want api_error", exitErr.Detail.Type)
|
||||
}
|
||||
if exitErr.Detail.Code != 1300002 {
|
||||
t.Errorf("error.code = %d, want 1300002", exitErr.Detail.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "(at statement 2 of 2)") {
|
||||
t.Errorf("error.message missing statement locator: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("error.detail not a map: %T", exitErr.Detail.Detail)
|
||||
}
|
||||
if detail["statement_index"] != 1 {
|
||||
t.Errorf("statement_index = %v, want 1", detail["statement_index"])
|
||||
}
|
||||
if detail["rolled_back"] != false {
|
||||
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", detail["rolled_back"])
|
||||
}
|
||||
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 1 {
|
||||
t.Errorf("completed = %v, want 1 persisted statement", detail["completed"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败(server 也返 code:0 + ERROR 哨兵)
|
||||
// 同样升级成 typed error:statement_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())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "(at statement 1 of 1)") {
|
||||
t.Errorf("error.message missing locator: %q", exitErr.Detail.Message)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if detail["statement_index"] != 0 {
|
||||
t.Errorf("statement_index = %v, want 0", detail["statement_index"])
|
||||
}
|
||||
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 0 {
|
||||
t.Errorf("completed = %v, want empty", detail["completed"])
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
87
shortcuts/apps/apps_db_table_get.go
Normal file
87
shortcuts/apps/apps_db_table_get.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// 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 / csv:CLI 不传 format query,response 含结构化
|
||||
// columns / indexes / constraints / stats,envelope 化输出。
|
||||
// - `--format pretty`:CLI 给 server 带 ?format=ddl,response 含 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
|
||||
}
|
||||
131
shortcuts/apps/apps_db_table_get_test.go
Normal file
131
shortcuts/apps/apps_db_table_get_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// 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-run,pretty 模式下 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)
|
||||
}
|
||||
}
|
||||
301
shortcuts/apps/apps_db_table_list.go
Normal file
301
shortcuts/apps/apps_db_table_list.go
Normal file
@@ -0,0 +1,301 @@
|
||||
// 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}/tables(cursor 分页),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 KB;1.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)
|
||||
}
|
||||
309
shortcuts/apps/apps_db_table_list_test.go
Normal file
309
shortcuts/apps/apps_db_table_list_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
// 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.* error(wire 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_1600000:Invalid DB Branch:dev",
|
||||
},
|
||||
})
|
||||
|
||||
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_count(mock 给了 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)
|
||||
}
|
||||
}
|
||||
380
shortcuts/apps/apps_env_pull.go
Normal file
380
shortcuts/apps/apps_env_pull.go
Normal file
@@ -0,0 +1,380 @@
|
||||
// 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"
|
||||
}
|
||||
1081
shortcuts/apps/apps_env_pull_test.go
Normal file
1081
shortcuts/apps/apps_env_pull_test.go
Normal file
File diff suppressed because it is too large
Load Diff
52
shortcuts/apps/apps_examples_test.go
Normal file
52
shortcuts/apps/apps_examples_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
shortcuts/apps/apps_hint_leak_test.go
Normal file
67
shortcuts/apps/apps_hint_leak_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
129
shortcuts/apps/apps_hints_more_test.go
Normal file
129
shortcuts/apps/apps_hints_more_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
91
shortcuts/apps/apps_hints_test.go
Normal file
91
shortcuts/apps/apps_hints_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,13 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
Command: "+html-publish",
|
||||
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
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,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
|
||||
{Name: "path", Desc: "path to HTML file or directory", Required: true},
|
||||
|
||||
674
shortcuts/apps/apps_init.go
Normal file
674
shortcuts/apps/apps_init.go
Normal file
@@ -0,0 +1,674 @@
|
||||
// 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)
|
||||
}
|
||||
1468
shortcuts/apps/apps_init_test.go
Normal file
1468
shortcuts/apps/apps_init_test.go
Normal file
File diff suppressed because it is too large
Load Diff
30
shortcuts/apps/apps_jq_tips_test.go
Normal file
30
shortcuts/apps/apps_jq_tips_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,23 +12,30 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsList lists Miaoda apps owned by the calling user (cursor pagination).
|
||||
// AppsList lists Miaoda apps visible to the calling user (cursor pagination).
|
||||
//
|
||||
// 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.
|
||||
// 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).
|
||||
var AppsList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+list",
|
||||
Description: "List Miaoda apps owned by the calling user (cursor pagination)",
|
||||
Description: "List Miaoda apps visible to the calling user (cursor pagination)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Hidden: true,
|
||||
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,
|
||||
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"},
|
||||
},
|
||||
@@ -39,18 +46,42 @@ var AppsList = common.Shortcut{
|
||||
Params(buildAppsListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPI("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
|
||||
data, err := rctx.CallAPITyped("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
// 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
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
// 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.
|
||||
// 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).
|
||||
rows := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
@@ -58,9 +89,11 @@ var AppsList = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"app_id": m["app_id"],
|
||||
"name": m["name"],
|
||||
"updated_at": m["updated_at"],
|
||||
"app_id": m["app_id"],
|
||||
"name": m["name"],
|
||||
"is_published": m["is_published"],
|
||||
"online_url": m["online_url"],
|
||||
"updated_at": m["updated_at"],
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
@@ -76,5 +109,14 @@ 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
|
||||
}
|
||||
|
||||
@@ -63,6 +63,56 @@ 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,
|
||||
@@ -78,3 +128,86 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
40
shortcuts/apps/apps_release_common.go
Normal file
40
shortcuts/apps/apps_release_common.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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)
|
||||
}
|
||||
76
shortcuts/apps/apps_release_create.go
Normal file
76
shortcuts/apps/apps_release_create.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// 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
|
||||
}
|
||||
107
shortcuts/apps/apps_release_create_test.go
Normal file
107
shortcuts/apps/apps_release_create_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// 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"])
|
||||
}
|
||||
}
|
||||
80
shortcuts/apps/apps_release_get.go
Normal file
80
shortcuts/apps/apps_release_get.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
300
shortcuts/apps/apps_release_get_test.go
Normal file
300
shortcuts/apps/apps_release_get_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// 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"])
|
||||
}
|
||||
}
|
||||
98
shortcuts/apps/apps_release_list.go
Normal file
98
shortcuts/apps/apps_release_list.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// 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
|
||||
}
|
||||
129
shortcuts/apps/apps_release_list_test.go
Normal file
129
shortcuts/apps/apps_release_list_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// 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"])
|
||||
}
|
||||
}
|
||||
58
shortcuts/apps/apps_session_create.go
Normal file
58
shortcuts/apps/apps_session_create.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// 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)))
|
||||
}
|
||||
85
shortcuts/apps/apps_session_create_test.go
Normal file
85
shortcuts/apps/apps_session_create_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
73
shortcuts/apps/apps_session_get.go
Normal file
73
shortcuts/apps/apps_session_get.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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)))
|
||||
}
|
||||
80
shortcuts/apps/apps_session_get_test.go
Normal file
80
shortcuts/apps/apps_session_get_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
79
shortcuts/apps/apps_session_list.go
Normal file
79
shortcuts/apps/apps_session_list.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// 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
|
||||
}
|
||||
89
shortcuts/apps/apps_session_list_test.go
Normal file
89
shortcuts/apps/apps_session_list_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
80
shortcuts/apps/apps_session_stop.go
Normal file
80
shortcuts/apps/apps_session_stop.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// 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")),
|
||||
}
|
||||
}
|
||||
110
shortcuts/apps/apps_session_stop_test.go
Normal file
110
shortcuts/apps/apps_session_stop_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
327
shortcuts/apps/apps_skill_consistency_test.go
Normal file
327
shortcuts/apps/apps_skill_consistency_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
// 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
|
||||
}
|
||||
@@ -20,9 +20,13 @@ var AppsUpdate = common.Shortcut{
|
||||
Command: "+update",
|
||||
Description: "Partially update a Miaoda app (only provided fields are sent)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
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,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "name", Desc: "new app display name"},
|
||||
@@ -48,9 +52,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.CallAPI("PATCH", path, nil, buildAppsUpdateBody(rctx))
|
||||
data, err := rctx.CallAPITyped("PATCH", path, nil, buildAppsUpdateBody(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "updated: %s\n", common.GetString(data, "app", "app_id"))
|
||||
|
||||
@@ -8,9 +8,46 @@ 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{
|
||||
|
||||
48
shortcuts/apps/command_runner.go
Normal file
48
shortcuts/apps/command_runner.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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}***@")
|
||||
}
|
||||
63
shortcuts/apps/command_runner_test.go
Normal file
63
shortcuts/apps/command_runner_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// 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
|
||||
}
|
||||
@@ -3,8 +3,50 @@
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
66
shortcuts/apps/common_test.go
Normal file
66
shortcuts/apps/common_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
43
shortcuts/apps/db_common.go
Normal file
43
shortcuts/apps/db_common.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
// 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 "", output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
41
shortcuts/apps/db_common_test.go
Normal file
41
shortcuts/apps/db_common_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
552
shortcuts/apps/git_credential.go
Normal file
552
shortcuts/apps/git_credential.go
Normal file
@@ -0,0 +1,552 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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("app_id", appID).
|
||||
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")
|
||||
},
|
||||
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,
|
||||
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
|
||||
}
|
||||
55
shortcuts/apps/git_credential_storage.go
Normal file
55
shortcuts/apps/git_credential_storage.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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
|
||||
}
|
||||
1022
shortcuts/apps/git_credential_test.go
Normal file
1022
shortcuts/apps/git_credential_test.go
Normal file
File diff suppressed because it is too large
Load Diff
138
shortcuts/apps/gitcred/gitconfig.go
Normal file
138
shortcuts/apps/gitcred/gitconfig.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type GitConfig interface {
|
||||
SetHelper(ctx context.Context, gitHTTPURL, appID string) error
|
||||
UnsetHelper(ctx context.Context, gitHTTPURL string) error
|
||||
}
|
||||
|
||||
type GlobalGitConfig struct {
|
||||
HelperCommand string
|
||||
}
|
||||
|
||||
func (g GlobalGitConfig) SetHelper(ctx context.Context, gitHTTPURL, appID string) error {
|
||||
normalizedURL, err := NormalizeGitHTTPURL(gitHTTPURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appID = strings.TrimSpace(appID)
|
||||
if err := validate.ResourceName(appID, "appID"); err != nil {
|
||||
return err
|
||||
}
|
||||
helper := g.helperCommand(appID)
|
||||
helperKey := gitCredentialKey(normalizedURL, "helper")
|
||||
useHTTPPathKey := gitCredentialKey(normalizedURL, "useHttpPath")
|
||||
previousHelper, hadHelper, err := gitConfigGet(ctx, helperKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hadHelper && previousHelper != helper && !g.isManagedHelper(previousHelper) {
|
||||
return fmt.Errorf("git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL)
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "git", "config", "--global", helperKey, helper).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "git", "config", "--global", useHTTPPathKey, "true").Run(); err != nil {
|
||||
if !hadHelper {
|
||||
_ = exec.CommandContext(ctx, "git", "config", "--global", "--unset", helperKey).Run()
|
||||
} else if previousHelper != helper {
|
||||
_ = exec.CommandContext(ctx, "git", "config", "--global", helperKey, previousHelper).Run()
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g GlobalGitConfig) UnsetHelper(ctx context.Context, gitHTTPURL string) error {
|
||||
normalizedURL, err := NormalizeGitHTTPURL(gitHTTPURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
helperKey := gitCredentialKey(normalizedURL, "helper")
|
||||
useHTTPPathKey := gitCredentialKey(normalizedURL, "useHttpPath")
|
||||
helper, found, err := gitConfigGet(ctx, helperKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
if !g.isManagedHelper(helper) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := gitConfigUnset(ctx, helperKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gitConfigUnset(ctx, useHTTPPathKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g GlobalGitConfig) helperCommand(appID string) string {
|
||||
if g.HelperCommand != "" {
|
||||
return g.HelperCommand
|
||||
}
|
||||
return "!lark-cli apps git-credential-helper --app-id " + shellQuoteArg(appID)
|
||||
}
|
||||
|
||||
func (g GlobalGitConfig) isManagedHelper(helper string) bool {
|
||||
helper = strings.TrimSpace(helper)
|
||||
if g.HelperCommand != "" {
|
||||
return helper == g.HelperCommand
|
||||
}
|
||||
return strings.HasPrefix(helper, "!lark-cli apps git-credential-helper ")
|
||||
}
|
||||
|
||||
func gitCredentialKey(gitHTTPURL, name string) string {
|
||||
return "credential." + gitHTTPURL + "." + name
|
||||
}
|
||||
|
||||
func gitConfigGet(ctx context.Context, key string) (string, bool, error) {
|
||||
out, err := exec.CommandContext(ctx, "git", "config", "--global", "--get", key).Output()
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(out)), true, nil
|
||||
}
|
||||
if isGitConfigGetMissing(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("get %s: %w", key, err)
|
||||
}
|
||||
|
||||
func gitConfigUnset(ctx context.Context, key string) error {
|
||||
if err := exec.CommandContext(ctx, "git", "config", "--global", "--unset", key).Run(); err != nil {
|
||||
if isGitConfigUnsetMissing(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unset %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isGitConfigGetMissing(err error) bool {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isGitConfigUnsetMissing(err error) bool {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 5 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shellQuoteArg(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
2381
shortcuts/apps/gitcred/gitcred_test.go
Normal file
2381
shortcuts/apps/gitcred/gitcred_test.go
Normal file
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
Reference in New Issue
Block a user