mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
1 Commits
v1.0.51
...
codex/insp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b54abe817c |
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
- 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)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -75,31 +75,7 @@ The one rule to internalize: **every error message you write will be parsed by a
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
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.
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
|
||||
83
CHANGELOG.md
83
CHANGELOG.md
@@ -2,86 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.51] - 2026-06-10
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Support multi dev modes (#1175)
|
||||
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
|
||||
- **base**: Configure initial base table schema (#1377)
|
||||
- **vc**: Add recording event support (#1369)
|
||||
- **minutes**: Replace words for transcript (#1372)
|
||||
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
|
||||
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
|
||||
- **slides**: Emit typed error envelopes across the slides domain (#1349)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
|
||||
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
|
||||
|
||||
## [v1.0.50] - 2026-06-09
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Emit typed error envelopes across the doc domain (#1346)
|
||||
- **event**: Emit typed error envelopes across the event domain (#1289)
|
||||
- **contact**: Emit typed error envelopes across the contact domain (#1287)
|
||||
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
|
||||
- **cli**: Adjust agent timeout hint output conditions (#1328)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
|
||||
- **slides**: Build create URL locally instead of drive metas call (#1329)
|
||||
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
|
||||
- **doc**: Document `<folder-manager>` resource block (#1168)
|
||||
- **drive**: Add drive comment location guidance (#1258)
|
||||
|
||||
## [v1.0.49] - 2026-06-08
|
||||
|
||||
### Features
|
||||
|
||||
- **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
|
||||
@@ -1106,9 +1026,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[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 | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
|
||||
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
|
||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -296,11 +296,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL.
|
||||
// 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.
|
||||
// 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).
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -318,9 +317,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -407,11 +404,10 @@ 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 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 {
|
||||
// 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 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
|
||||
@@ -12,7 +12,6 @@ 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"
|
||||
@@ -39,8 +38,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"set up bus logger: %s", err).WithCause(err)
|
||||
return err
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
@@ -60,14 +58,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}()
|
||||
|
||||
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
|
||||
return b.Run(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// 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,7 +16,6 @@ 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"
|
||||
@@ -65,8 +64,8 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
@@ -102,10 +101,11 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
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)
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,9 +184,8 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
|
||||
// Bounded runs already have --max-events/--timeout as their lifecycle control.
|
||||
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
@@ -261,12 +260,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
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))
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
@@ -301,27 +300,23 @@ func preflightEventTypes(pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
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))
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
// 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 "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirTilde)
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirUnsafe)
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
@@ -333,21 +328,18 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"resolve tenant access token: %s", err).WithCause(err)
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
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 "", 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 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")
|
||||
@@ -359,10 +351,7 @@ func parseParams(raw []string) (map[string]string, error) {
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||
WithParam("--param").
|
||||
WithCause(errInvalidParamFormat)
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
@@ -381,8 +370,3 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
|
||||
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
|
||||
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
|
||||
return !isTerminal && maxEvents <= 0 && timeout <= 0
|
||||
}
|
||||
|
||||
@@ -61,70 +61,3 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldWatchStdinEOF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isTerminal bool
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
isTerminal: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal unbounded",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative max events is unbounded",
|
||||
maxEvents: -1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative timeout is unbounded",
|
||||
timeout: -1 * time.Second,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal max events bounded",
|
||||
maxEvents: 1,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal timeout bounded",
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal both bounds positive",
|
||||
maxEvents: 1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded max events with negative timeout",
|
||||
maxEvents: 1,
|
||||
timeout: -1 * time.Second,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded timeout with negative max events",
|
||||
maxEvents: -1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
|
||||
if got != tt.want {
|
||||
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,9 @@
|
||||
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) {
|
||||
@@ -78,7 +73,6 @@ 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 {
|
||||
@@ -96,77 +90,6 @@ 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
|
||||
@@ -207,7 +130,6 @@ 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,17 +89,19 @@ 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)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
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)
|
||||
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")
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,19 +145,17 @@ 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 permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
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.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
}
|
||||
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)
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
}
|
||||
hint := permErr.Hint
|
||||
hint := exit.Detail.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,11 +26,7 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
return nil, err
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
@@ -40,20 +36,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
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)
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
return nil, err
|
||||
}
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
// 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,7 +11,6 @@ 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"
|
||||
@@ -40,14 +39,12 @@ 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, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||
return nil, nil, err
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
@@ -76,7 +73,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -168,7 +165,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
|
||||
@@ -10,7 +10,6 @@ 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"
|
||||
@@ -130,38 +129,3 @@ 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,6 +64,9 @@ func unknownEventKeyErr(key string) error {
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"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,8 +16,7 @@ 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, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// 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,11 +6,12 @@ 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"
|
||||
)
|
||||
|
||||
@@ -147,8 +148,9 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
|
||||
}
|
||||
|
||||
func isLarkCode(err error, code int) bool {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
return p.Code == code
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return exitErr.Detail.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,8 +16,7 @@ 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, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1.
|
||||
type VCRecordingEndedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.recording.recording_ended_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
EventTime string `json:"event_time,omitempty" desc:"Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||
}
|
||||
|
||||
type recordingEndedEnvelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event recordingEndedEvent `json:"event"`
|
||||
}
|
||||
|
||||
type recordingEndedEvent struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
envelope, ok := parseRecordingEndedEnvelope(raw)
|
||||
if !ok {
|
||||
return raw.Payload, nil
|
||||
}
|
||||
if !isRecordingEndedBeanEvent(envelope) {
|
||||
return nil, nil
|
||||
}
|
||||
out := &VCRecordingEndedOutput{
|
||||
Type: recordingEndedEventType(envelope, raw),
|
||||
EventID: envelope.Header.EventID,
|
||||
EventTime: recordingEndedEventTime(envelope.Header.CreateTime),
|
||||
UniqueKey: envelope.Event.UniqueKey,
|
||||
Source: envelope.Event.Source,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) {
|
||||
var envelope recordingEndedEnvelope
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &envelope, true
|
||||
}
|
||||
|
||||
func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool {
|
||||
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||
}
|
||||
|
||||
func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string {
|
||||
if envelope != nil && envelope.Header.EventType != "" {
|
||||
return envelope.Header.EventType
|
||||
}
|
||||
return raw.EventType
|
||||
}
|
||||
|
||||
func recordingEndedEventTime(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1.
|
||||
type VCRecordingStartedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.recording.recording_started_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
EventTime string `json:"event_time,omitempty" desc:"Recording start time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||
}
|
||||
|
||||
type recordingStartedEnvelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event recordingStartedEvent `json:"event"`
|
||||
}
|
||||
|
||||
type recordingStartedEvent struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
envelope, ok := parseRecordingStartedEnvelope(raw)
|
||||
if !ok {
|
||||
return raw.Payload, nil
|
||||
}
|
||||
if !isRecordingStartedBeanEvent(envelope) {
|
||||
return nil, nil
|
||||
}
|
||||
out := &VCRecordingStartedOutput{
|
||||
Type: recordingStartedEventType(envelope, raw),
|
||||
EventID: envelope.Header.EventID,
|
||||
EventTime: recordingStartedEventTime(envelope.Header.CreateTime),
|
||||
UniqueKey: envelope.Event.UniqueKey,
|
||||
Source: envelope.Event.Source,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) {
|
||||
var envelope recordingStartedEnvelope
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &envelope, true
|
||||
}
|
||||
|
||||
func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool {
|
||||
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||
}
|
||||
|
||||
func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string {
|
||||
if envelope != nil && envelope.Header.EventType != "" {
|
||||
return envelope.Header.EventType
|
||||
}
|
||||
return raw.EventType
|
||||
}
|
||||
|
||||
func recordingStartedEventTime(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestVCKeys_RecordingEventsRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
eventType string
|
||||
}{
|
||||
{eventTypeRecordingStarted},
|
||||
{eventTypeRecordingTranscriptGenerated},
|
||||
{eventTypeRecordingEnded},
|
||||
} {
|
||||
t.Run(tc.eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(tc.eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:recording:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
|
||||
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
|
||||
}
|
||||
if !strings.Contains(def.Description, "recording_bean") {
|
||||
t.Errorf("Description should document recording_bean source, got %q", def.Description)
|
||||
}
|
||||
if !strings.Contains(def.Description, "connected to Feishu software") {
|
||||
t.Errorf("Description should document Feishu software connection requirement, got %q", def.Description)
|
||||
}
|
||||
if strings.Contains(def.Description, "future") || strings.Contains(def.Description, "software_recording") {
|
||||
t.Errorf("Description should not mention future sources, got %q", def.Description)
|
||||
}
|
||||
if tc.eventType == eventTypeRecordingEnded && (strings.Contains(def.Description, "object_type") || strings.Contains(def.Description, "object_id")) {
|
||||
t.Errorf("ended Description should not document object metadata, got %q", def.Description)
|
||||
}
|
||||
wantSchemaType := reflect.TypeOf(VCRecordingStartedOutput{})
|
||||
switch tc.eventType {
|
||||
case eventTypeRecordingTranscriptGenerated:
|
||||
wantSchemaType = reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})
|
||||
case eventTypeRecordingEnded:
|
||||
wantSchemaType = reflect.TypeOf(VCRecordingEndedOutput{})
|
||||
}
|
||||
if def.Schema.Custom.Type != wantSchemaType {
|
||||
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, wantSchemaType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingStarted(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
out := runRecordingProcess[VCRecordingStartedOutput](t, eventTypeRecordingStarted, processVCRecordingStarted, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_start_001",
|
||||
"event_type": "vc.recording.recording_started_v1",
|
||||
"create_time": "1761782400000"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean"
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeRecordingStarted {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_rec_start_001" || out.EventTime != recordingTestEventTime(1761782400000) {
|
||||
t.Errorf("EventID/EventTime = %q/%q", out.EventID, out.EventTime)
|
||||
}
|
||||
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingTranscriptGenerated(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
got := runRecordingProcessRaw(t, eventTypeRecordingTranscriptGenerated, processVCRecordingTranscriptGenerated, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_transcript_001",
|
||||
"event_type": "vc.recording.recording_transcript_generated_v1",
|
||||
"create_time": "1761782400100"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean",
|
||||
"transcript_items": [
|
||||
{
|
||||
"speaker": {
|
||||
"id": {
|
||||
"open_id": "ou_0f8bf7acdf2ae69553ecbdbfbbd10a53",
|
||||
"union_id": "on_bc03f16d781bff4178a5d11e48eb1867",
|
||||
"user_id": null
|
||||
},
|
||||
"user_type": 100,
|
||||
"user_role": 1,
|
||||
"user_name": "Alice"
|
||||
},
|
||||
"text": "hello world",
|
||||
"language": "en_us",
|
||||
"start_time_ms": "1761782399000",
|
||||
"end_time_ms": "1761782400000",
|
||||
"sentence_id": "987654321"
|
||||
},
|
||||
{
|
||||
"speaker": {
|
||||
"user_name": "Bob"
|
||||
},
|
||||
"text": "second sentence",
|
||||
"language": "en_us",
|
||||
"start_time_ms": "1761782401000",
|
||||
"end_time_ms": "1761782402000",
|
||||
"sentence_id": "987654322"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
if got == nil {
|
||||
t.Fatal("Process output is nil")
|
||||
}
|
||||
var out VCRecordingTranscriptGeneratedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
|
||||
if out.Type != eventTypeRecordingTranscriptGenerated {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||
}
|
||||
if out.EventTime != recordingTestEventTime(1761782400100) {
|
||||
t.Errorf("EventTime = %q", out.EventTime)
|
||||
}
|
||||
if len(out.TranscriptItems) != 2 {
|
||||
t.Fatalf("TranscriptItems len = %d, want 2", len(out.TranscriptItems))
|
||||
}
|
||||
item := out.TranscriptItems[0]
|
||||
if item.SpeakerName != "Alice" || item.Text != "hello world" {
|
||||
t.Errorf("Transcript speaker/text = %q/%q", item.SpeakerName, item.Text)
|
||||
}
|
||||
if item.StartTime != recordingTestEventTime(1761782399000) || item.EndTime != recordingTestEventTime(1761782400000) {
|
||||
t.Errorf("Transcript timing = %q/%q", item.StartTime, item.EndTime)
|
||||
}
|
||||
if item.SentenceID != "987654321" {
|
||||
t.Errorf("SentenceID = %q, want 987654321", item.SentenceID)
|
||||
}
|
||||
if out.TranscriptItems[1].SpeakerName != "Bob" || out.TranscriptItems[1].SentenceID != "987654322" {
|
||||
t.Errorf("second transcript item = %+v", out.TranscriptItems[1])
|
||||
}
|
||||
itemJSON, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal transcript item: %v", err)
|
||||
}
|
||||
var itemFields map[string]any
|
||||
if err := json.Unmarshal(itemJSON, &itemFields); err != nil {
|
||||
t.Fatalf("unmarshal transcript item JSON: %v", err)
|
||||
}
|
||||
wantItemFields := map[string]bool{
|
||||
"speaker_name": true,
|
||||
"text": true,
|
||||
"start_time": true,
|
||||
"end_time": true,
|
||||
"sentence_id": true,
|
||||
}
|
||||
for gotField := range itemFields {
|
||||
if !wantItemFields[gotField] {
|
||||
t.Errorf("Transcript item should not contain field %q, got %s", gotField, string(itemJSON))
|
||||
}
|
||||
}
|
||||
for wantField := range wantItemFields {
|
||||
if _, ok := itemFields[wantField]; !ok {
|
||||
t.Errorf("Transcript item missing field %q, got %s", wantField, string(itemJSON))
|
||||
}
|
||||
}
|
||||
for _, unexpected := range []string{
|
||||
`"seq_id"`,
|
||||
`"speaker"`,
|
||||
`"user_open_id"`,
|
||||
`"user_type"`,
|
||||
`"user_role"`,
|
||||
`"language"`,
|
||||
`"start_time_ms"`,
|
||||
`"end_time_ms"`,
|
||||
`"sequence_id"`,
|
||||
`"transcript_item"`,
|
||||
} {
|
||||
if strings.Contains(string(got), unexpected) {
|
||||
t.Errorf("Transcript output should not contain %s, got %s", unexpected, string(got))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(got), `"sentence_id":"987654321"`) {
|
||||
t.Errorf("Transcript output should contain sentence_id, got %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingEnded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
out := runRecordingProcess[VCRecordingEndedOutput](t, eventTypeRecordingEnded, processVCRecordingEnded, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_end_001",
|
||||
"event_type": "vc.recording.recording_ended_v1",
|
||||
"create_time": "1761782400200"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean",
|
||||
"object_type": "minutes",
|
||||
"object_id": "minute_token_001"
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeRecordingEnded {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||
}
|
||||
if out.EventTime != recordingTestEventTime(1761782400200) {
|
||||
t.Errorf("EventTime = %q", out.EventTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingEnded_DropsObjectMetadata(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
got := runRecordingProcessRaw(t, eventTypeRecordingEnded, processVCRecordingEnded, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_end_001",
|
||||
"event_type": "vc.recording.recording_ended_v1",
|
||||
"create_time": "1761782400200"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean",
|
||||
"object_type": "minutes",
|
||||
"object_id": "minute_token_001"
|
||||
}
|
||||
}`)
|
||||
|
||||
if strings.Contains(string(got), "object_type") || strings.Contains(string(got), "object_id") {
|
||||
t.Fatalf("ended output should drop object metadata, got %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecording_DropsTimestampField(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
got := runRecordingProcessRaw(t, eventTypeRecordingStarted, processVCRecordingStarted, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_start_001",
|
||||
"event_type": "vc.recording.recording_started_v1",
|
||||
"create_time": "1761782400000"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean"
|
||||
}
|
||||
}`)
|
||||
|
||||
if strings.Contains(string(got), `"timestamp"`) {
|
||||
t.Fatalf("recording output should use event_time instead of timestamp, got %s", string(got))
|
||||
}
|
||||
if !strings.Contains(string(got), `"event_time":"`+recordingTestEventTime(1761782400000)+`"`) {
|
||||
t.Fatalf("recording output should include ISO 8601 event_time, got %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
payload string
|
||||
}{
|
||||
{
|
||||
name: "started",
|
||||
eventType: eventTypeRecordingStarted,
|
||||
process: processVCRecordingStarted,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "ev_rec_start_001", "event_type": "vc.recording.recording_started_v1"},
|
||||
"event": {"unique_key": "recording_001", "source": "software_recording"}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "transcript",
|
||||
eventType: eventTypeRecordingTranscriptGenerated,
|
||||
process: processVCRecordingTranscriptGenerated,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "ev_rec_transcript_001", "event_type": "vc.recording.recording_transcript_generated_v1"},
|
||||
"event": {"unique_key": "recording_001", "source": "software_recording", "transcript_items": []}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "ended",
|
||||
eventType: eventTypeRecordingEnded,
|
||||
process: processVCRecordingEnded,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "ev_rec_end_001", "event_type": "vc.recording.recording_ended_v1"},
|
||||
"event": {"unique_key": "recording_001", "source": "software_recording"}
|
||||
}`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := runRecordingProcessRaw(t, tc.eventType, tc.process, tc.payload)
|
||||
if got != nil {
|
||||
t.Fatalf("non-recording_bean event should be filtered, got %s", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{name: "started", eventType: eventTypeRecordingStarted, process: processVCRecordingStarted},
|
||||
{name: "transcript", eventType: eventTypeRecordingTranscriptGenerated, process: processVCRecordingTranscriptGenerated},
|
||||
{name: "ended", eventType: eventTypeRecordingEnded, process: processVCRecordingEnded},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: tc.eventType,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
eventType string
|
||||
}{
|
||||
{eventTypeRecordingStarted},
|
||||
{eventTypeRecordingTranscriptGenerated},
|
||||
{eventTypeRecordingEnded},
|
||||
} {
|
||||
t.Run(tc.eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(tc.eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||
}
|
||||
|
||||
type call struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}
|
||||
var calls []call
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
calls = append(calls, call{method: method, path: path, body: body})
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup must not be nil")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||
}
|
||||
if calls[0].method != "POST" || calls[0].path != pathRecordingSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, tc.eventType)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathRecordingUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, tc.eventType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runRecordingProcess[T any](t *testing.T, eventType string, process event.ProcessFunc, payload string) T {
|
||||
t.Helper()
|
||||
got := runRecordingProcessRaw(t, eventType, process, payload)
|
||||
if got == nil {
|
||||
t.Fatal("Process output is nil")
|
||||
}
|
||||
var out T
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runRecordingProcessRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventType,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func recordingTestEventTime(millis int64) string {
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events.
|
||||
type VCRecordingTranscriptItemOutput struct {
|
||||
SpeakerName string `json:"speaker_name,omitempty" desc:"Speaker display name"`
|
||||
Text string `json:"text,omitempty" desc:"Transcript text"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Transcript item start time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
EndTime string `json:"end_time,omitempty" desc:"Transcript item end time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
SentenceID string `json:"sentence_id,omitempty" desc:"Transcript sentence ID"`
|
||||
}
|
||||
|
||||
// VCRecordingTranscriptGeneratedOutput is the flattened shape for vc.recording.recording_transcript_generated_v1.
|
||||
type VCRecordingTranscriptGeneratedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.recording.recording_transcript_generated_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
EventTime string `json:"event_time,omitempty" desc:"Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||
TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedEnvelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event recordingTranscriptGeneratedEvent `json:"event"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedEvent struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Source string `json:"source"`
|
||||
TranscriptItems []recordingTranscriptGeneratedItemIn `json:"transcript_items"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedItemIn struct {
|
||||
Speaker *recordingTranscriptGeneratedSpeakerIn `json:"speaker"`
|
||||
Text string `json:"text"`
|
||||
StartTimeMs recordingTranscriptGeneratedString `json:"start_time_ms"`
|
||||
EndTimeMs recordingTranscriptGeneratedString `json:"end_time_ms"`
|
||||
SentenceID string `json:"sentence_id"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedSpeakerIn struct {
|
||||
UserName string `json:"user_name"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedString string
|
||||
|
||||
func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw)
|
||||
if !ok {
|
||||
return raw.Payload, nil
|
||||
}
|
||||
if !isRecordingTranscriptGeneratedBeanEvent(envelope) {
|
||||
return nil, nil
|
||||
}
|
||||
out := &VCRecordingTranscriptGeneratedOutput{
|
||||
Type: recordingTranscriptGeneratedEventType(envelope, raw),
|
||||
EventID: envelope.Header.EventID,
|
||||
EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime),
|
||||
UniqueKey: envelope.Event.UniqueKey,
|
||||
Source: envelope.Event.Source,
|
||||
TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems),
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) {
|
||||
var envelope recordingTranscriptGeneratedEnvelope
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &envelope, true
|
||||
}
|
||||
|
||||
func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool {
|
||||
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||
}
|
||||
|
||||
func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string {
|
||||
if envelope != nil && envelope.Header.EventType != "" {
|
||||
return envelope.Header.EventType
|
||||
}
|
||||
return raw.EventType
|
||||
}
|
||||
|
||||
func recordingTranscriptGeneratedEventTime(raw string) string {
|
||||
return recordingTranscriptGeneratedMillisToLocalRFC3339(raw)
|
||||
}
|
||||
|
||||
func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]VCRecordingTranscriptItemOutput, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, recordingTranscriptItem(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordingTranscriptItemOutput {
|
||||
return VCRecordingTranscriptItemOutput{
|
||||
SpeakerName: recordingSpeakerName(item.Speaker),
|
||||
Text: item.Text,
|
||||
StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()),
|
||||
EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()),
|
||||
SentenceID: item.SentenceID,
|
||||
}
|
||||
}
|
||||
|
||||
func recordingSpeakerName(speaker *recordingTranscriptGeneratedSpeakerIn) string {
|
||||
if speaker == nil {
|
||||
return ""
|
||||
}
|
||||
return speaker.UserName
|
||||
}
|
||||
|
||||
func (s *recordingTranscriptGeneratedString) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
*s = recordingTranscriptGeneratedString(str)
|
||||
return nil
|
||||
}
|
||||
var num json.Number
|
||||
if err := json.Unmarshal(data, &num); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = recordingTranscriptGeneratedString(num.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s recordingTranscriptGeneratedString) String() string {
|
||||
return string(s)
|
||||
}
|
||||
@@ -11,18 +11,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
||||
eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1"
|
||||
eventTypeRecordingEnded = "vc.recording.recording_ended_v1"
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
||||
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
||||
pathRecordingSubscribe = "/open-apis/vc/v1/recordings/subscription"
|
||||
pathRecordingUnsubscribe = "/open-apis/vc/v1/recordings/unsubscription"
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
||||
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
||||
|
||||
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
|
||||
)
|
||||
@@ -62,53 +57,5 @@ func Keys() []event.KeyDefinition {
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
|
||||
},
|
||||
{
|
||||
Key: eventTypeRecordingStarted,
|
||||
DisplayName: "Recording started",
|
||||
Description: "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.",
|
||||
EventType: eventTypeRecordingStarted,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})},
|
||||
},
|
||||
Process: processVCRecordingStarted,
|
||||
PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||
Scopes: []string{"vc:recording:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingStarted},
|
||||
},
|
||||
{
|
||||
Key: eventTypeRecordingTranscriptGenerated,
|
||||
DisplayName: "Recording transcript generated",
|
||||
Description: "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.",
|
||||
EventType: eventTypeRecordingTranscriptGenerated,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})},
|
||||
},
|
||||
Process: processVCRecordingTranscriptGenerated,
|
||||
PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||
Scopes: []string{"vc:recording:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingTranscriptGenerated},
|
||||
},
|
||||
{
|
||||
Key: eventTypeRecordingEnded,
|
||||
DisplayName: "Recording ended",
|
||||
Description: "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.",
|
||||
EventType: eventTypeRecordingEnded,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})},
|
||||
},
|
||||
Process: processVCRecordingEnded,
|
||||
PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||
Scopes: []string{"vc:recording:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingEnded},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
@@ -25,15 +24,11 @@ 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, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
whiteboardID := params["whiteboard_id"]
|
||||
if whiteboardID == "" {
|
||||
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)
|
||||
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
|
||||
}
|
||||
encoded := validate.EncodePathSegment(whiteboardID)
|
||||
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -59,16 +58,6 @@ 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
|
||||
@@ -81,9 +70,6 @@ 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
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
@@ -45,9 +44,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
keyDef, ok := event.Lookup(opts.EventKey)
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown EventKey: %s", opts.EventKey).
|
||||
WithHint("run `lark-cli event list` to see available keys")
|
||||
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
|
||||
}
|
||||
|
||||
if err := validateParams(keyDef, opts.Params); err != nil {
|
||||
@@ -83,8 +80,7 @@ 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 errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
return fmt.Errorf("handshake failed: %w", err)
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
@@ -94,11 +90,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pre-consume failed: %s", err).WithCause(err)
|
||||
return fmt.Errorf("pre-consume failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +130,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintln(errOut, listeningText(opts))
|
||||
if !opts.IsTTY {
|
||||
fmt.Fprintln(errOut, stopHintText(opts))
|
||||
fmt.Fprintln(errOut, stopHintText())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +152,8 @@ 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 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)
|
||||
return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
|
||||
p.Name, def.Key, def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,15 +169,11 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
continue
|
||||
}
|
||||
if len(validNames) == 0 {
|
||||
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: 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 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 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 nil
|
||||
}
|
||||
@@ -227,11 +213,7 @@ func exitReason(ctx context.Context, emitted int64, opts Options) string {
|
||||
return "signal"
|
||||
}
|
||||
|
||||
func stopHintText(opts Options) string {
|
||||
if opts.MaxEvents > 0 || opts.Timeout > 0 {
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>). " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
func stopHintText() string {
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
|
||||
@@ -8,21 +8,17 @@ 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, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
|
||||
return nil, fmt.Errorf("invalid jq expression: %w", err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
|
||||
return nil, fmt.Errorf("jq compile error: %w", err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
@@ -50,32 +50,12 @@ func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Unbounded(t *testing.T) {
|
||||
got := stopHintText(Options{})
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup", "close stdin"}
|
||||
func TestStopHintText_Content(t *testing.T) {
|
||||
got := stopHintText()
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText(unbounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Bounded(t *testing.T) {
|
||||
cases := []Options{
|
||||
{MaxEvents: 1},
|
||||
{Timeout: 30 * time.Second},
|
||||
}
|
||||
for _, opts := range cases {
|
||||
got := stopHintText(opts)
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText(bounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
if bytes.Contains([]byte(got), []byte("close stdin")) {
|
||||
t.Errorf("stopHintText(bounded) must not contain \"close stdin\"; got %q", got)
|
||||
t.Errorf("stopHintText missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,10 @@ package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
@@ -23,16 +20,6 @@ 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,7 +13,6 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -24,8 +23,7 @@ type Sink interface {
|
||||
func newSink(opts Options) (Sink, error) {
|
||||
if opts.OutputDir != "" {
|
||||
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"create output dir: %s", err).WithCause(err)
|
||||
return nil, fmt.Errorf("create output dir: %w", err)
|
||||
}
|
||||
// PID disambiguates filenames across processes sharing a Dir.
|
||||
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil
|
||||
|
||||
@@ -16,7 +16,6 @@ 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"
|
||||
@@ -52,9 +51,10 @@ 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, 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")
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -65,10 +65,8 @@ 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, 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)
|
||||
return nil, fmt.Errorf("failed to start event bus daemon: %w\n"+
|
||||
"Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
|
||||
}
|
||||
if pid > 0 {
|
||||
announceForkedBus(errOut, pid)
|
||||
@@ -90,9 +88,7 @@ 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, 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)
|
||||
return nil, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
|
||||
}
|
||||
|
||||
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// 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,21 +15,12 @@ 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,21 +16,12 @@ 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,11 +27,6 @@ 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
|
||||
@@ -41,9 +36,6 @@ 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)
|
||||
@@ -79,16 +71,3 @@ 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,7 +620,6 @@ func boom() error {
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"shortcuts/markdown/markdown_fetch.go",
|
||||
"shortcuts/okr/okr_image_upload.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_update.go",
|
||||
@@ -692,7 +691,7 @@ func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
@@ -814,8 +813,6 @@ 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
|
||||
@@ -836,8 +833,6 @@ 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
|
||||
@@ -858,8 +853,6 @@ 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
|
||||
@@ -914,7 +907,7 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
@@ -951,13 +944,9 @@ 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",
|
||||
}
|
||||
@@ -1008,74 +997,6 @@ func boom() {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversDocPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/doc/docs_fetch_v2.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversSheetsPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/sheets/helpers.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on sheets path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversSlidesPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/slides/slides_create.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on slides path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversMarkdownPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/markdown/markdown_fetch.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on markdown path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package contact
|
||||
|
||||
@@ -1085,7 +1006,7 @@ func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
@@ -1155,23 +1076,3 @@ 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.51",
|
||||
"version": "1.0.48",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -21,12 +21,9 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
Command: "+access-scope-get",
|
||||
Description: "Get Miaoda app access scope configuration",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +access-scope-get --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
},
|
||||
@@ -45,9 +42,9 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
data, err := rctx.CallAPI("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||
return err
|
||||
}
|
||||
// 原样透传 — 保留服务端字符串枚举 (All/Tenant/Range),不合并 users/departments/chats。
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
|
||||
@@ -27,14 +27,9 @@ var AppsAccessScopeSet = common.Shortcut{
|
||||
Command: "+access-scope-set",
|
||||
Description: "Set Miaoda app access scope (specific / public / tenant)",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope tenant`,
|
||||
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope public --require-login`,
|
||||
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope specific --targets '[{"type":"user","id":"<open_id>"}]'`,
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "scope", Desc: "scope: specific | public | tenant", Required: true, Enum: []string{"specific", "public", "tenant"}},
|
||||
@@ -69,9 +64,9 @@ var AppsAccessScopeSet = common.Shortcut{
|
||||
}
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("PUT", path, nil, body)
|
||||
data, err := rctx.CallAPI("PUT", path, nil, body)
|
||||
if err != nil {
|
||||
return withAppsHint(err, "verify --app-id is correct; for scope=specific, each --targets id must be a valid open_id/department_id/chat_id and --approver a valid open_id; review the current scope with `lark-cli apps +access-scope-get --app-id <app_id>`")
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "access-scope set: %s\n", rctx.Str("scope"))
|
||||
|
||||
@@ -8,62 +8,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func testRuntimeAccessScope(t *testing.T, scope, targets, approver string, applyEnabled, requireLogin bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "access-scope-set"}
|
||||
cmd.Flags().String("scope", scope, "")
|
||||
cmd.Flags().String("targets", targets, "")
|
||||
cmd.Flags().String("approver", approver, "")
|
||||
cmd.Flags().Bool("apply-enabled", applyEnabled, "")
|
||||
cmd.Flags().Bool("require-login", requireLogin, "")
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
func TestBuildAccessScopeBody_Branches(t *testing.T) {
|
||||
t.Run("invalid scope", func(t *testing.T) {
|
||||
if _, err := buildAccessScopeBody(testRuntimeAccessScope(t, "bogus", "", "", false, false)); err == nil {
|
||||
t.Error("unknown scope must error")
|
||||
}
|
||||
})
|
||||
t.Run("specific with all target kinds and approver", func(t *testing.T) {
|
||||
body, err := buildAccessScopeBody(testRuntimeAccessScope(t,
|
||||
"specific",
|
||||
`[{"type":"user","id":"u1"},{"type":"department","id":"d1"},{"type":"chat","id":"c1"}]`,
|
||||
"ou_appr", true, false))
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body["scope"] != "Range" {
|
||||
t.Errorf("scope=%v want Range", body["scope"])
|
||||
}
|
||||
for _, k := range []string{"users", "departments", "chats", "apply_config"} {
|
||||
if _, ok := body[k]; !ok {
|
||||
t.Errorf("missing %q in body=%v", k, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("specific with invalid targets JSON", func(t *testing.T) {
|
||||
if _, err := buildAccessScopeBody(testRuntimeAccessScope(t, "specific", "{bad", "", false, false)); err == nil {
|
||||
t.Error("invalid targets JSON must error")
|
||||
}
|
||||
})
|
||||
t.Run("public sets require_login", func(t *testing.T) {
|
||||
body, err := buildAccessScopeBody(testRuntimeAccessScope(t, "public", "", "", false, true))
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body["scope"] != "All" || body["require_login"] != true {
|
||||
t.Errorf("public body=%v", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_Specific(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
@@ -254,44 +201,3 @@ func TestAppsAccessScopeSet_TrimsAppIDInPath(t *testing.T) {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAccessScopeTargets_Partitions(t *testing.T) {
|
||||
users, departments, chats := splitAccessScopeTargets([]map[string]interface{}{
|
||||
{"type": "user", "id": "u1"},
|
||||
{"type": "department", "id": "d1"},
|
||||
{"type": "chat", "id": "c1"},
|
||||
{"type": "user", "id": " "}, // empty id skipped
|
||||
{"type": "unknown", "id": "x"}, // unknown type skipped
|
||||
})
|
||||
if len(users) != 1 || users[0] != "u1" {
|
||||
t.Errorf("users=%v want [u1]", users)
|
||||
}
|
||||
if len(departments) != 1 || departments[0] != "d1" {
|
||||
t.Errorf("departments=%v want [d1]", departments)
|
||||
}
|
||||
if len(chats) != 1 || chats[0] != "c1" {
|
||||
t.Errorf("chats=%v want [c1]", chats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTargetsJSON_Cases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantErr bool
|
||||
}{
|
||||
{"invalid json", "{not json", true},
|
||||
{"empty array", "[]", true},
|
||||
{"bad type", `[{"type":"role","id":"r1"}]`, true},
|
||||
{"empty id", `[{"type":"user","id":" "}]`, true},
|
||||
{"valid", `[{"type":"user","id":"u1"}]`, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := validateTargetsJSON(c.in)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Errorf("validateTargetsJSON(%q) err=%v wantErr=%v", c.in, err, c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAppsList_503IsRetryableTypedError pins the typed-error upgrade: a 5xx
|
||||
// response from the apps list endpoint must surface as a typed errs.Problem with
|
||||
// Retryable == true (via CallAPITyped → httpStatusError). The pre-migration
|
||||
// CallAPI path produced a legacy *output.ExitError with no Retryable field, so
|
||||
// this test fails until AppsList is migrated to CallAPITyped.
|
||||
func TestAppsList_503IsRetryableTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Status: 503,
|
||||
// A gateway-style non-JSON body (text/html) forces the status-based
|
||||
// classifier (httpStatusError) rather than the API-envelope path.
|
||||
Headers: http.Header{"Content-Type": []string{"text/html"}},
|
||||
RawBody: []byte("<html><body>503 Service Unavailable</body></html>"),
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error on 503, got nil; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.Problem on 503, got %T: %v", err, err)
|
||||
}
|
||||
if !p.Retryable {
|
||||
t.Fatalf("expected Retryable == true on 503, got Problem=%+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsList_SuccessShapeUnchanged pins that the success path is
|
||||
// output-shape-neutral after migration: a 200 envelope still yields a success
|
||||
// stdout envelope carrying the app_id.
|
||||
func TestAppsList_SuccessShapeUnchanged(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"app_id": "a", "name": "n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"app_id": "a"`) {
|
||||
t.Fatalf("stdout missing app_id: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsChat sends a user message to a session, starting/continuing a conversation.
|
||||
// Async: the message is queued and the response carries no business payload (no
|
||||
// turn_id, no next_poll_after_ms — the turn is not generated yet). Poll
|
||||
// +session-get; it returns next_poll_after_ms, and once the turn runs its handle
|
||||
// is in latest_turn.turn_id.
|
||||
|
||||
// Turn cost varies sharply by init state: the first +chat on a not-initialized
|
||||
// app runs a one-time design + first-generation pass server-side (~20-50 min);
|
||||
// chat on an already-initialized app is incremental and finishes in minutes.
|
||||
// The init-state check and matching polling cadence live in the lark-apps
|
||||
// skill reference (references/lark-apps-cloud-dev.md) — the canonical source.
|
||||
var AppsChat = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+chat",
|
||||
Description: "Send a message to a session to start/continue a conversation",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message "做一个待办清单页面"`,
|
||||
`Example: lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message "把首页标题改为 我的待办"`,
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "session-id", Desc: "session ID", Required: true},
|
||||
{Name: "message", Desc: "user message text", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
}
|
||||
// Do not echo --message content in the error (spec §4 redaction).
|
||||
if strings.TrimSpace(rctx.Str("message")) == "" {
|
||||
return output.ErrValidation("--message is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST(chatPath(rctx.Str("app-id"), rctx.Str("session-id"))).
|
||||
Desc("Send a message to a session").
|
||||
Body(buildChatBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("POST", chatPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, buildChatBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, "if the session_id is unknown or invalid, list this app's sessions with `lark-cli apps +session-list --app-id "+strings.TrimSpace(rctx.Str("app-id"))+"`")
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "message sent; poll +session-get for turn status\n")
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func chatPath(appID, sessionID string) string {
|
||||
return sessionPath(appID, sessionID) + "/chat"
|
||||
}
|
||||
|
||||
func buildChatBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"message": strings.TrimSpace(rctx.Str("message")),
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsChat_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
// +chat is async and returns NO business payload (no turn_id, no
|
||||
// next_poll_after_ms — the turn is not generated yet). turn_id and the
|
||||
// poll interval are read later from +session-get.
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "把首页表头改成蓝色", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["message"] != "把首页表头改成蓝色" {
|
||||
t.Fatalf("body.message = %v", sent["message"])
|
||||
}
|
||||
if _, present := sent["attachment_ids"]; present {
|
||||
t.Fatalf("attachment_ids must not be sent this iteration: %v", sent)
|
||||
}
|
||||
// +chat carries no next_poll_after_ms; the CLI must not fabricate one.
|
||||
if got := stdout.String(); strings.Contains(got, "next_poll_after_ms") {
|
||||
t.Fatalf("stdout must not reference next_poll_after_ms (chat returns none): %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsChat_Pretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "hi", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "message sent") || !strings.Contains(got, "+session-get") {
|
||||
t.Fatalf("pretty wrong: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsChat_RequiresMessage(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "message") {
|
||||
t.Fatalf("expected --message required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Security: a non-blank message that fails for another reason must never be echoed.
|
||||
// Here we assert the blank-message error names the field only (no content leak path).
|
||||
func TestAppsChat_ValidationDoesNotEchoMessage(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// blank message triggers validation; the error must mention the flag, not any content.
|
||||
err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "", "--session-id", "conv_x", "--message", "secret-content-xyz", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-content-xyz") {
|
||||
t.Fatalf("validation error must not echo --message content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsChat_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "hi", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"message": "hi"`) {
|
||||
t.Fatalf("dry-run missing message body: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -13,24 +13,18 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create Miaoda apps"
|
||||
|
||||
// AppsCreate creates a new Miaoda app.
|
||||
var AppsCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+create",
|
||||
Description: "Create a new Miaoda app",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +create --name "审批系统" --app-type full_stack`,
|
||||
`Example: lark-cli apps +create --name "活动页" --app-type html --description "活动报名"`,
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "app display name", Required: true},
|
||||
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}},
|
||||
{Name: "app-type", Desc: "app type (currently only: HTML)", Required: true},
|
||||
{Name: "description", Desc: "app description"},
|
||||
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
|
||||
},
|
||||
@@ -38,6 +32,13 @@ var AppsCreate = common.Shortcut{
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return output.ErrValidation("--name is required")
|
||||
}
|
||||
appType := strings.TrimSpace(rctx.Str("app-type"))
|
||||
if appType == "" {
|
||||
return output.ErrValidation("--app-type is required")
|
||||
}
|
||||
if !validAppTypes[appType] {
|
||||
return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML)", appType))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -47,9 +48,9 @@ var AppsCreate = common.Shortcut{
|
||||
Body(buildAppsCreateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
|
||||
data, err := rctx.CallAPI("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, createHint)
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "created: %s\n", common.GetString(data, "app", "app_id"))
|
||||
@@ -58,13 +59,15 @@ var AppsCreate = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// 应用类型枚举。当前只有 HTML,未来会扩展(SPA、NATIVE、...)。
|
||||
var validAppTypes = map[string]bool{
|
||||
"HTML": true,
|
||||
}
|
||||
|
||||
func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
// --app-type is constrained to the lowercase enum (html / full_stack) by the
|
||||
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
|
||||
// a server concern and is intentionally not surfaced by the CLI.
|
||||
body := map[string]interface{}{
|
||||
"name": strings.TrimSpace(rctx.Str("name")),
|
||||
"app_type": rctx.Str("app-type"),
|
||||
"app_type": strings.TrimSpace(rctx.Str("app-type")),
|
||||
}
|
||||
if desc := strings.TrimSpace(rctx.Str("description")); desc != "" {
|
||||
body["description"] = desc
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
|
||||
func newAppsExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
@@ -69,7 +68,7 @@ func TestAppsCreate_Success(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--description", "d", "--as", "user"},
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--description", "d", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -84,8 +83,8 @@ func TestAppsCreate_Success(t *testing.T) {
|
||||
if sent["name"] != "Demo" {
|
||||
t.Fatalf("body.name = %v", sent["name"])
|
||||
}
|
||||
if sent["app_type"] != "html" {
|
||||
t.Fatalf("body.app_type = %v (want html)", sent["app_type"])
|
||||
if sent["app_type"] != "HTML" {
|
||||
t.Fatalf("body.app_type = %v (want HTML)", sent["app_type"])
|
||||
}
|
||||
if sent["description"] != "d" {
|
||||
t.Fatalf("body.description = %v", sent["description"])
|
||||
@@ -109,7 +108,7 @@ func TestAppsCreate_WithIconURL(t *testing.T) {
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -134,7 +133,7 @@ func TestAppsCreate_PrettyOutputReadsNestedAppID(t *testing.T) {
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--format", "pretty", "--as", "user"},
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -145,7 +144,7 @@ func TestAppsCreate_PrettyOutputReadsNestedAppID(t *testing.T) {
|
||||
|
||||
func TestAppsCreate_RequiresName(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "html", "--as", "user"}, factory, stdout)
|
||||
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "HTML", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "name") {
|
||||
t.Fatalf("expected name required error, got %v", err)
|
||||
}
|
||||
@@ -160,31 +159,20 @@ func TestAppsCreate_RequiresAppType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCreate_RejectsInvalidAppType pins that --app-type is a strict
|
||||
// lowercase enum (html / full_stack). Unknown values and legacy uppercase are
|
||||
// both rejected by the flag's Enum — the CLI does not normalize case; legacy
|
||||
// uppercase compatibility is a server-side concern, not surfaced by the client.
|
||||
func TestAppsCreate_RejectsInvalidAppType(t *testing.T) {
|
||||
for _, appType := range []string{"spa", "HTML", "Full_Stack"} {
|
||||
t.Run(appType, func(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", appType, "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid value") {
|
||||
t.Fatalf("expected invalid-enum error for %q, got %v", appType, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "full_stack") {
|
||||
t.Fatalf("expected enum error to list allowed values, got %v", err)
|
||||
}
|
||||
})
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "spa", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported") {
|
||||
t.Fatalf("expected unsupported app-type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--dry-run", "--as", "user"},
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -195,55 +183,7 @@ func TestAppsCreate_DryRun(t *testing.T) {
|
||||
if !strings.Contains(got, `"name": "Demo"`) {
|
||||
t.Fatalf("dry-run missing body: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"app_type": "html"`) {
|
||||
if !strings.Contains(got, `"app_type": "HTML"`) {
|
||||
t.Fatalf("dry-run missing app_type: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_FullstackSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_fs", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "full_stack", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["app_type"] != "full_stack" {
|
||||
t.Fatalf("body.app_type = %v (want full_stack)", sent["app_type"])
|
||||
}
|
||||
if _, present := sent["message"]; present {
|
||||
t.Fatalf("message should never be sent: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_FullstackDryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "full_stack", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"app_type": "full_stack"`) {
|
||||
t.Fatalf("dry-run missing app_type full_stack: %s", got)
|
||||
}
|
||||
if strings.Contains(got, `"message"`) {
|
||||
t.Fatalf("dry-run should not contain message: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBEnvCreate creates a DB environment for a Miaoda app(拆分单库为 dev/online 多环境)。
|
||||
//
|
||||
// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。
|
||||
// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
|
||||
var AppsDBEnvCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-env-create",
|
||||
Description: "Create a DB environment (split single-env DB into dev/online, irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-create --env dev --sync-data --app-id <app_id> --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"},
|
||||
{Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appDbEnvCreatePath(appID)).
|
||||
Desc("Create Miaoda app DB environment").
|
||||
Body(buildDBEnvCreateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", appDbEnvCreatePath(appID), nil, buildDBEnvCreateBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvCreateHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderEnvCreatePretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildDBEnvCreateBody 构造 db 环境创建 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.")
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsDBEnvCreate_WithYesPostsSyncData(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init", // URL 仍走 db_dev_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)
|
||||
}
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/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
|
||||
}
|
||||
@@ -1,797 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbTableGetHint = "verify --app-id and --table are correct; list tables with `lark-cli apps +db-table-list --app-id <app_id>`; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBTableGet gets one table's structure (动词对齐 +db-table-list)。
|
||||
//
|
||||
// GET /apps/{app_id}/tables/{table_name}。
|
||||
//
|
||||
// `--format` 同时驱动 CLI 渲染和 server 请求形态:
|
||||
// - `--format json`(默认)/ table / ndjson / 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
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsDBTableGet_DefaultJSONReturnsStructuredFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/tables/orders",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"name": "orders",
|
||||
"description": "订单表",
|
||||
"columns": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "id", "data_type": "int8",
|
||||
"is_primary_key": true, "is_unique": true,
|
||||
"is_allow_null": false, "default_value": "",
|
||||
},
|
||||
},
|
||||
"indexes": []interface{}{
|
||||
map[string]interface{}{"name": "orders_pkey", "type": "btree", "columns": []interface{}{"id"}, "definition": "..."},
|
||||
},
|
||||
"constraints": []interface{}{
|
||||
map[string]interface{}{"type": "primary_key", "name": "orders_pkey", "columns": []interface{}{"id"}},
|
||||
},
|
||||
"estimated_row_count": 1200,
|
||||
"size_bytes": 81920,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsDBTableGet,
|
||||
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"name": "orders"`) {
|
||||
t.Fatalf("stdout missing schema name: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --format pretty 是触发 DDL 模式的唯一开关。
|
||||
// 用 --format json + --dry-run 走 JSON envelope 路径方便 parse,但 query 形态由代码内部
|
||||
// 根据 rctx.Format 决定 —— 这里我们直接传 --format pretty + --dry-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)
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbTableListHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBTableList lists tables in a Miaoda app's database.
|
||||
//
|
||||
// GET /apps/{app_id}/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)
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope 验证 server 业务错误
|
||||
// (code != 0,如单环境 app 查 env=dev 返 "Invalid DB Branch")被 CLI 透出成
|
||||
// typed error —— 用 BOE 实测的错误码 / 文案做输入。
|
||||
//
|
||||
// 迁移到 runtime.CallAPITyped 后,非零 code 的业务错误由 errclass.BuildAPIError
|
||||
// 归类为 typed errs.* 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)
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// envKeyPattern matches valid environment variable names: [A-Za-z_][A-Za-z0-9_]*
|
||||
var envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
type envPullDatabaseInfo struct {
|
||||
Detected bool
|
||||
ExpiresAtRaw string
|
||||
ExpiresAtText string
|
||||
}
|
||||
|
||||
// AppsEnvPull pulls startup env vars for an app into the local .env.local file.
|
||||
var AppsEnvPull = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+env-pull",
|
||||
Description: "Pull app startup env vars into the local project .env.local",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +env-pull --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID"},
|
||||
{Name: "project-path", Desc: "local project root path (defaults to current directory)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: "--app-id is required"}, Param: "app-id"}
|
||||
}
|
||||
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
if err != nil {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
|
||||
}
|
||||
if err := checkEnvPullTarget(envFile); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Pull app startup env vars into the local .env.local file").
|
||||
Set("project_path", projectPath).
|
||||
Set("env_file", envFile)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
if err != nil {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
|
||||
}
|
||||
if err := checkEnvPullTarget(envFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rctx.EnsureScopes([]string{"spark:app:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||
}
|
||||
|
||||
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if envVars == nil {
|
||||
envVars = map[string]string{}
|
||||
}
|
||||
envVars["FORCE_DB_BRANCH"] = "dev"
|
||||
original, err := readEnvPullFile(envFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged, updated, created := mergeEnvPullFileContent(original, envVars)
|
||||
if err := ensureEnvPullParentDir(envFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.AtomicWrite(envFile, []byte(merged), 0o600); err != nil {
|
||||
return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot write %s: %v", envFile, err)}, Cause: err}
|
||||
}
|
||||
|
||||
result := buildEnvPullSuccessData(appID, envFile, databaseInfo)
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
writeEnvPullPretty(w, appID, envFile, databaseInfo, skippedKeys)
|
||||
})
|
||||
_ = updated
|
||||
_ = created
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
||||
if strings.TrimSpace(projectPath) == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
projectPath = cwd
|
||||
}
|
||||
if err := validate.RejectControlChars(projectPath, "--project-path"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
projectPath = filepath.Clean(projectPath)
|
||||
return projectPath, filepath.Join(projectPath, ".env.local"), nil
|
||||
}
|
||||
|
||||
func checkEnvPullTarget(envFile string) error {
|
||||
info, err := os.Lstat(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; direct lstat is needed to reject symlinks before write.
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("cannot inspect %s: %v", envFile, err)}, Param: "project-path", Cause: err}
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file, not a symlink", envFile)}, Param: "project-path"}
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file", envFile)}, Param: "project-path"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPullDatabaseInfo, []string, error) {
|
||||
raw := data["env_vars"]
|
||||
if raw == nil {
|
||||
if nested, ok := data["data"].(map[string]interface{}); ok {
|
||||
raw = nested["env_vars"]
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
|
||||
}
|
||||
|
||||
var skippedKeys []string
|
||||
switch typed := raw.(type) {
|
||||
case map[string]interface{}:
|
||||
out := make(map[string]string, len(typed))
|
||||
for key, value := range typed {
|
||||
if !envKeyPattern.MatchString(key) {
|
||||
skippedKeys = append(skippedKeys, key)
|
||||
continue
|
||||
}
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[key] = s
|
||||
}
|
||||
return out, envPullDatabaseInfo{Detected: hasEnvPullDatabase(out)}, skippedKeys, nil
|
||||
case []interface{}:
|
||||
out := make(map[string]string, len(typed))
|
||||
info := envPullDatabaseInfo{}
|
||||
for _, item := range typed {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, ok := entry["key"].(string)
|
||||
if !ok || strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
if !envKeyPattern.MatchString(key) {
|
||||
skippedKeys = append(skippedKeys, key)
|
||||
continue
|
||||
}
|
||||
value, ok := entry["value"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
if key == "SUDA_DATABASE_URL" {
|
||||
info.Detected = true
|
||||
info.ExpiresAtRaw, info.ExpiresAtText = extractEnvPullDatabaseExpiry(entry["extras"])
|
||||
}
|
||||
}
|
||||
return out, info, skippedKeys, nil
|
||||
default:
|
||||
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
|
||||
}
|
||||
}
|
||||
|
||||
func readEnvPullFile(envFile string) (string, error) {
|
||||
data, err := os.ReadFile(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; validated local file read for a single env file.
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot read %s: %v", envFile, err)}, Cause: err}
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func ensureEnvPullParentDir(envFile string) error {
|
||||
dir := filepath.Dir(envFile)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local mkdir for target env parent dir.
|
||||
return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot create %s: %v", dir, err)}, Cause: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeEnvPullFileContent(original string, envVars map[string]string) (string, []string, []string) {
|
||||
if len(envVars) == 0 {
|
||||
if original == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
return ensureTrailingNewline(original), nil, nil
|
||||
}
|
||||
|
||||
normalized := strings.ReplaceAll(original, "\r\n", "\n")
|
||||
lines := []string{}
|
||||
if normalized != "" {
|
||||
lines = strings.Split(normalized, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
}
|
||||
|
||||
used := make(map[string]bool, len(envVars))
|
||||
updated := make([]string, 0, len(envVars))
|
||||
for i, line := range lines {
|
||||
key, ok := parseEnvPullAssignmentLine(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value, exists := envVars[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
lines[i] = formatEnvPullAssignment(key, value)
|
||||
updated = append(updated, key)
|
||||
used[key] = true
|
||||
}
|
||||
|
||||
created := make([]string, 0, len(envVars))
|
||||
pending := make([]string, 0, len(envVars))
|
||||
for key := range envVars {
|
||||
if used[key] {
|
||||
continue
|
||||
}
|
||||
pending = append(pending, key)
|
||||
}
|
||||
sort.Strings(pending)
|
||||
for _, key := range pending {
|
||||
lines = append(lines, formatEnvPullAssignment(key, envVars[key]))
|
||||
created = append(created, key)
|
||||
}
|
||||
|
||||
sort.Strings(updated)
|
||||
content := strings.Join(lines, "\n")
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
return content, updated, created
|
||||
}
|
||||
|
||||
func parseEnvPullAssignmentLine(line string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "export ") || strings.HasPrefix(trimmed, "export\t") {
|
||||
remainder := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(trimmed, "export "), "export\t"))
|
||||
if remainder == "" || strings.HasPrefix(remainder, "=") {
|
||||
return "", false
|
||||
}
|
||||
trimmed = remainder
|
||||
}
|
||||
idx := strings.Index(trimmed, "=")
|
||||
if idx <= 0 {
|
||||
return "", false
|
||||
}
|
||||
key := strings.TrimSpace(trimmed[:idx])
|
||||
if key == "" || strings.ContainsAny(key, " \t") {
|
||||
return "", false
|
||||
}
|
||||
return key, true
|
||||
}
|
||||
|
||||
func formatEnvPullAssignment(key, value string) string {
|
||||
return fmt.Sprintf("%s=%s", key, strconv.Quote(value))
|
||||
}
|
||||
|
||||
func buildEnvPullSuccessData(appID, envFile string, databaseInfo envPullDatabaseInfo) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"env_file": envFile,
|
||||
}
|
||||
if databaseInfo.ExpiresAtRaw != "" {
|
||||
result["database_url_expires_at"] = databaseInfo.ExpiresAtRaw
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hasEnvPullDatabase(envVars map[string]string) bool {
|
||||
_, ok := envVars["SUDA_DATABASE_URL"]
|
||||
return ok
|
||||
}
|
||||
|
||||
func extractEnvPullDatabaseExpiry(rawExtras interface{}) (string, string) {
|
||||
extras, ok := rawExtras.([]interface{})
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
for _, raw := range extras {
|
||||
entry, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, _ := entry["key"].(string)
|
||||
if key != "expiresAt" {
|
||||
continue
|
||||
}
|
||||
switch value := entry["value"].(type) {
|
||||
case string:
|
||||
rawValue := strings.TrimSpace(value)
|
||||
ts, err := strconv.ParseInt(rawValue, 10, 64)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
|
||||
case float64:
|
||||
ts := int64(value)
|
||||
rawValue := strconv.FormatInt(ts, 10)
|
||||
return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func writeEnvPullPretty(w io.Writer, appID, envFile string, databaseInfo envPullDatabaseInfo, skippedKeys []string) {
|
||||
fmt.Fprintf(w, "✓ App detected: %s\n", appID)
|
||||
if databaseInfo.Detected {
|
||||
fmt.Fprintln(w, "✓ Development database detected")
|
||||
}
|
||||
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
|
||||
if databaseInfo.ExpiresAtText != "" {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "DATABASE_URL is valid until %s.\n", databaseInfo.ExpiresAtText)
|
||||
}
|
||||
if len(skippedKeys) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "⚠ Skipped %d invalid key(s): %s (key names must match [A-Za-z_][A-Za-z0-9_]*)\n", len(skippedKeys), strings.Join(skippedKeys, ", "))
|
||||
}
|
||||
fmt.Fprintf(w, "Run `lark-cli apps +env-pull --app-id <app_id>` again to refresh it.\n")
|
||||
}
|
||||
|
||||
func ensureTrailingNewline(s string) string {
|
||||
if s == "" || strings.HasSuffix(s, "\n") {
|
||||
return s
|
||||
}
|
||||
return s + "\n"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppsShortcutsHaveExamples(t *testing.T) {
|
||||
realAppID := regexp.MustCompile(`app_[a-z0-9]{6,}`)
|
||||
email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
|
||||
phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`)
|
||||
for _, s := range Shortcuts() {
|
||||
hasExample := false
|
||||
for _, tip := range s.Tips {
|
||||
if strings.HasPrefix(tip, "Example: lark-cli apps +") {
|
||||
hasExample = true
|
||||
}
|
||||
if realAppID.MatchString(tip) {
|
||||
t.Errorf("%s tip leaks real-looking app id (use <app_id>): %q", s.Command, tip)
|
||||
}
|
||||
if email.MatchString(tip) || phone.MatchString(tip) {
|
||||
t.Errorf("%s tip leaks PII: %q", s.Command, tip)
|
||||
}
|
||||
}
|
||||
if !hasExample {
|
||||
t.Errorf("%s has no \"Example: lark-cli apps +...\" tip", s.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) {
|
||||
want := map[string]int{"+chat": 2, "+access-scope-set": 2}
|
||||
for _, s := range Shortcuts() {
|
||||
min, ok := want[s.Command]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n := 0
|
||||
for _, tip := range s.Tips {
|
||||
if strings.HasPrefix(tip, "Example: lark-cli apps +") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n < min {
|
||||
t.Errorf("%s has %d Example tips, want >= %d", s.Command, n, min)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAppsErrorHintsCarryNoSecretsOrPII guards the actionable error hints added
|
||||
// for the apps command-governance task. Those hints are inline string literals
|
||||
// spread across several files (apps_env_pull.go, apps_access_scope_set.go,
|
||||
// apps_access_scope_get.go, apps_init.go git-push path, and the
|
||||
// gitCredentialIssueHint const in git_credential.go). They are stable English
|
||||
// strings, so we assert the verbatim copies here: a real app_id, an email, or a
|
||||
// phone number must never appear in a hint. Placeholders like <app_id> are
|
||||
// expected and must NOT trip the real-app-id regex.
|
||||
func TestAppsErrorHintsCarryNoSecretsOrPII(t *testing.T) {
|
||||
// These are copied verbatim from the source. If a hint changes, copy the new
|
||||
// text here so this leak guard keeps tracking the real production string.
|
||||
hints := []string{
|
||||
// apps_env_pull.go:86 and apps_access_scope_get.go:50 (identical literals)
|
||||
"verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`",
|
||||
// apps_access_scope_set.go:74
|
||||
"verify --app-id is correct; for scope=specific, each --targets id must be a valid open_id/department_id/chat_id and --approver a valid open_id; review the current scope with `lark-cli apps +access-scope-get --app-id <app_id>`",
|
||||
// apps_init.go:483 (git push rejection)
|
||||
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded",
|
||||
// git_credential.go gitCredentialIssueHint const (referenced directly so a
|
||||
// rename or text change breaks the build instead of silently drifting)
|
||||
gitCredentialIssueHint,
|
||||
// command-governance hints added for this task (referenced by const, no drift)
|
||||
appIDListHint,
|
||||
sessionStopHint,
|
||||
createHint,
|
||||
dbEnvCreateHint,
|
||||
dbTableGetHint,
|
||||
dbTableListHint,
|
||||
}
|
||||
|
||||
realAppID := regexp.MustCompile(`app_[a-z0-9]{6,}`)
|
||||
email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
|
||||
phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`)
|
||||
// An obvious secret: a PAT-like token or a "secret=..." / "token=..." pair.
|
||||
secret := regexp.MustCompile(`(?i)(pat-[a-z0-9]+|secret\s*[=:]\s*\S|token\s*[=:]\s*\S)`)
|
||||
|
||||
for _, h := range hints {
|
||||
if realAppID.MatchString(h) {
|
||||
t.Errorf("hint leaks a real-looking app id (use <app_id>): %q", h)
|
||||
}
|
||||
if email.MatchString(h) {
|
||||
t.Errorf("hint leaks an email address: %q", h)
|
||||
}
|
||||
if phone.MatchString(h) {
|
||||
t.Errorf("hint leaks a phone number: %q", h)
|
||||
}
|
||||
if secret.MatchString(h) {
|
||||
t.Errorf("hint leaks an obvious secret/token: %q", h)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the placeholder <app_id> must NOT match the real-app-id regex,
|
||||
// otherwise the guard above would be a false positive on legitimate hints.
|
||||
if realAppID.MatchString("<app_id>") {
|
||||
t.Fatal("realAppID regex incorrectly matches the <app_id> placeholder")
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func assertHintContains(t *testing.T, sc common.Shortcut, args []string, stub *httpmock.Stub, want string) {
|
||||
t.Helper()
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(stub)
|
||||
err := runAppsShortcut(t, sc, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Fatalf("hint %q does not contain %q", p.Hint, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionCreate_4xxFailureCarriesListHint(t *testing.T) {
|
||||
assertHintContains(t, AppsSessionCreate,
|
||||
[]string{"+session-create", "--app-id", "app_x", "--as", "user"},
|
||||
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
||||
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "app not found"}},
|
||||
"apps +list")
|
||||
}
|
||||
|
||||
func TestAppsSessionList_4xxFailureCarriesListHint(t *testing.T) {
|
||||
assertHintContains(t, AppsSessionList,
|
||||
[]string{"+session-list", "--app-id", "app_x", "--as", "user"},
|
||||
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
||||
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
|
||||
"apps +list")
|
||||
}
|
||||
|
||||
func TestAppsUpdate_4xxFailureCarriesListHint(t *testing.T) {
|
||||
assertHintContains(t, AppsUpdate,
|
||||
[]string{"+update", "--app-id", "app_x", "--name", "n", "--as", "user"},
|
||||
&httpmock.Stub{Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x",
|
||||
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "app not found"}},
|
||||
"apps +list")
|
||||
}
|
||||
|
||||
func TestAppsReleaseList_4xxFailureCarriesListHint(t *testing.T) {
|
||||
assertHintContains(t, AppsReleaseList,
|
||||
[]string{"+release-list", "--app-id", "app_x", "--as", "user"},
|
||||
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases",
|
||||
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
|
||||
"apps +list")
|
||||
}
|
||||
|
||||
func TestAppsSessionStop_4xxFailureCarriesSessionHint(t *testing.T) {
|
||||
assertHintContains(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "s1", "--turn-id", "t1", "--as", "user"},
|
||||
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/sessions/s1/stop",
|
||||
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "session not found"}},
|
||||
"+session-list")
|
||||
}
|
||||
|
||||
func TestAppsCreate_4xxFailureCarriesTypeHint(t *testing.T) {
|
||||
assertHintContains(t, AppsCreate,
|
||||
[]string{"+create", "--name", "n", "--app-type", "html", "--as", "user"},
|
||||
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps",
|
||||
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
|
||||
"full_stack")
|
||||
}
|
||||
|
||||
func TestAppsDBEnvCreate_4xxFailureCarriesHint(t *testing.T) {
|
||||
assertHintContains(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"},
|
||||
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
|
||||
Status: http.StatusConflict, Body: map[string]interface{}{"msg": "already multi-env"}},
|
||||
"+db-table-list")
|
||||
}
|
||||
|
||||
func TestAppsDBTableGet_4xxFailureCarriesHint(t *testing.T) {
|
||||
assertHintContains(t, AppsDBTableGet,
|
||||
[]string{"+db-table-get", "--app-id", "app_x", "--table", "users", "--as", "user"},
|
||||
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables/users",
|
||||
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "table not found"}},
|
||||
"+db-table-list")
|
||||
}
|
||||
|
||||
func TestAppsDBTableList_4xxFailureCarriesHint(t *testing.T) {
|
||||
assertHintContains(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"},
|
||||
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables",
|
||||
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "dev env not found"}},
|
||||
"+db-env-create")
|
||||
}
|
||||
|
||||
// withAppsHint must only fill an EMPTY hint; an upstream-provided hint wins.
|
||||
func TestWithAppsHint_DoesNotOverrideUpstreamHint(t *testing.T) {
|
||||
upstream := &errs.Problem{Message: "boom", Hint: "upstream specific hint"}
|
||||
got := withAppsHint(upstream, appIDListHint)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", got)
|
||||
}
|
||||
if p.Hint != "upstream specific hint" {
|
||||
t.Fatalf("upstream hint was overridden: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// withAppsHint fills the hint when empty and leaves Message untouched.
|
||||
func TestWithAppsHint_FillsEmptyHintKeepsMessage(t *testing.T) {
|
||||
p0 := &errs.Problem{Message: "boom"}
|
||||
got := withAppsHint(p0, appIDListHint)
|
||||
p, _ := errs.ProblemOf(got)
|
||||
if p.Hint != appIDListHint {
|
||||
t.Fatalf("hint not filled: %q", p.Hint)
|
||||
}
|
||||
if p.Message != "boom" {
|
||||
t.Fatalf("message mutated: %q", p.Message)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAppsEnvPull_4xxFailureCarriesListHint verifies that a 4xx failure from the
|
||||
// env_vars endpoint surfaces an actionable hint pointing at `lark-cli apps +list`.
|
||||
func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
Status: http.StatusForbidden,
|
||||
Body: map[string]interface{}{"msg": "permission denied"},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsEnvPull,
|
||||
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "apps +list") {
|
||||
t.Fatalf("hint missing `apps +list`: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsAccessScopeGet_4xxFailureCarriesListHint verifies the access-scope-get
|
||||
// 4xx failure points at `lark-cli apps +list`.
|
||||
func TestAppsAccessScopeGet_4xxFailureCarriesListHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
|
||||
Status: http.StatusNotFound,
|
||||
Body: map[string]interface{}{"msg": "app not found"},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsAccessScopeGet,
|
||||
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "apps +list") {
|
||||
t.Fatalf("hint missing `apps +list`: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsAccessScopeSet_4xxFailureCarriesScopeGetHint verifies the
|
||||
// access-scope-set 4xx failure points at `+access-scope-get`.
|
||||
func TestAppsAccessScopeSet_4xxFailureCarriesScopeGetHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
|
||||
Status: http.StatusBadRequest,
|
||||
Body: map[string]interface{}{"msg": "invalid target id"},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsAccessScopeSet,
|
||||
[]string{"+access-scope-set", "--app-id", "app_x", "--scope", "tenant", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+access-scope-get") {
|
||||
t.Fatalf("hint missing `+access-scope-get`: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
@@ -21,13 +21,9 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
Command: "+html-publish",
|
||||
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./dist",
|
||||
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./site --dry-run",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
|
||||
{Name: "path", Desc: "path to HTML file or directory", Required: true},
|
||||
|
||||
@@ -1,674 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// defaultInitBranch is the fixed remote branch +init checks out after clone.
|
||||
const defaultInitBranch = "sprint/default"
|
||||
|
||||
// Fixed init commit subjects. Constants — never interpolate user input. The
|
||||
// empty-repo (`app init`) path splits the scaffolded tree into two commits;
|
||||
// the non-empty (`app sync`) path stays a single commit.
|
||||
const (
|
||||
commitMsgAppCode = "chore: initialize app project code"
|
||||
commitMsgAppConfig = "chore: initialize miaoda app config"
|
||||
commitMsgUpgrade = "chore: initialize miaoda app repository"
|
||||
)
|
||||
|
||||
// scaffold kinds returned by runScaffold and consumed by commitAndPushIfDirty.
|
||||
const (
|
||||
scaffoldKindInit = "init"
|
||||
scaffoldKindUpgrade = "upgrade"
|
||||
)
|
||||
|
||||
const (
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
|
||||
defaultTemplate = "nestjs-react-fullstack"
|
||||
metaRelPath = ".spark/meta.json"
|
||||
steeringRelPath = ".agent/skills/steering"
|
||||
seedReadme = "README.md"
|
||||
)
|
||||
|
||||
// initRunner is the commandRunner used by +init. Package-level so unit tests
|
||||
// can swap in a fakeCommandRunner. Production uses execCommandRunner.
|
||||
var initRunner commandRunner = execCommandRunner{}
|
||||
|
||||
// AppsInit initializes a Miaoda app's code and local development environment.
|
||||
var AppsInit = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+init",
|
||||
Description: "Initialize a Miaoda app's code and local development environment",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +init --app-id <app_id> --dir <dir>",
|
||||
"Example: lark-cli apps +init --app-id <app_id> --dir <dir> --dry-run",
|
||||
},
|
||||
// +init makes no direct lark API calls (it shells out to the
|
||||
// +git-credential-init subprocess, which enforces its own scopes), so it
|
||||
// declares no scopes of its own. Explicit []string{} (not nil) per the
|
||||
// convention enforced by TestAllShortcutsScopesNotNil.
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
// NOTE: --app-id is intentionally NOT Required:true. The framework maps
|
||||
// Required:true to cobra's MarkFlagRequired, whose error is plain-text
|
||||
// exit-1 (root.go handleRootError case 4), bypassing the structured
|
||||
// envelope. The spec and the E2E assert exit-2 + a structured
|
||||
// {"ok":false,"error":{...}} envelope for missing --app-id, so the empty
|
||||
// check lives in Validate (output.ErrValidation -> ExitValidation=2).
|
||||
{Name: "app-id", Desc: "Miaoda app ID"},
|
||||
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
|
||||
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
template := resolveTemplate(rctx, appID)
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Initialize Miaoda app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
|
||||
Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
|
||||
Set("checkout", "git checkout "+defaultInitBranch).
|
||||
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
|
||||
Set("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes").
|
||||
Set("template", template).
|
||||
Set("env_pull", fmt.Sprintf("apps +env-pull --app-id %s --project-path <clone_path> --format json (after successful init)", appID))
|
||||
dir, err := resolveTargetPath(rctx, appID)
|
||||
if err != nil {
|
||||
dry.Set("dir_error", err.Error())
|
||||
dir = defaultCloneDir(appID)
|
||||
} else if isAlreadyInitialized(dir) {
|
||||
dry.Set("already_initialized", true)
|
||||
} else if e := ensureEmptyDir(dir); e != nil {
|
||||
dry.Set("dir_error", e.Error())
|
||||
}
|
||||
dry.Set("clone", fmt.Sprintf("git clone -- <repository_url-from-credential-init> %s", dir))
|
||||
dry.Set("clone_path", dir)
|
||||
return dry
|
||||
},
|
||||
Execute: appsInitExecute,
|
||||
}
|
||||
|
||||
// defaultCloneDir returns the default clone target (./<app-id>) for an app ID.
|
||||
func defaultCloneDir(appID string) string {
|
||||
return filepath.Join(".", appID)
|
||||
}
|
||||
|
||||
// resolveTemplate returns the scaffold template for an empty-repo `app init`.
|
||||
// An explicit --template wins. When omitted, it should be derived from the
|
||||
// app's tech stack.
|
||||
// TODO(apps-init): look up the app by appID via the apps API (e.g. `apps +list`
|
||||
// or a get-app endpoint), read its tech stack, and map tech-stack -> template
|
||||
// through a (future) enum. Until that lands, fall back to defaultTemplate.
|
||||
func resolveTemplate(rctx *common.RuntimeContext, appID string) string {
|
||||
if t := strings.TrimSpace(rctx.Str("template")); t != "" {
|
||||
return t
|
||||
}
|
||||
// TODO(apps-init): derive from app tech stack (apps API + enum mapping).
|
||||
return defaultTemplate
|
||||
}
|
||||
|
||||
// initLogf writes a one-line progress message to stderr. stdout stays reserved
|
||||
// for the structured JSON envelope, so progress never pollutes it. Callers must
|
||||
// never pass a raw repository_url (it may embed a token) — pass step names,
|
||||
// clone_path, branch, or scaffold kind, and route any URL through
|
||||
// redactURLCredentials first.
|
||||
func initLogf(rctx *common.RuntimeContext, format string, args ...interface{}) {
|
||||
fmt.Fprintf(rctx.IO().ErrOut, "→ "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// resolveTargetPath computes the absolute clone target from --dir (or the
|
||||
// ./<app-id> default). Unlike the prior SafeInputPath approach it does NOT
|
||||
// confine to cwd — the clone destination is user-chosen (the skill prompts for
|
||||
// it). It rejects empty input and control characters; symlink/no-clobber
|
||||
// guarding happens in ensureEmptyDir.
|
||||
func resolveTargetPath(rctx *common.RuntimeContext, appID string) (string, error) {
|
||||
raw := strings.TrimSpace(rctx.Str("dir"))
|
||||
if raw == "" {
|
||||
raw = defaultCloneDir(appID)
|
||||
}
|
||||
// Reject ALL control characters (incl. tab/newline — a newline in an echoed
|
||||
// path is a log-injection vector); charcheck additionally rejects dangerous
|
||||
// Unicode (bidi overrides, zero-width) that IsControl does not.
|
||||
if strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return "", output.ErrValidation("--dir must not contain control characters")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(raw, "--dir"); err != nil {
|
||||
return "", output.ErrValidation("%v", err)
|
||||
}
|
||||
abs, err := filepath.Abs(raw) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); raw is control-char-validated above, and FileIO.ResolvePath cannot resolve a clone target (it rejects absolute paths).
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("--dir cannot be resolved: %v", err)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// ensureEmptyDir refuses to clone into an existing non-empty dir, a symlink, or
|
||||
// a non-directory. A non-existent path is fine (git clone creates it). Uses
|
||||
// Lstat so a symlinked target is rejected rather than followed.
|
||||
func ensureEmptyDir(dir string) error {
|
||||
info, err := os.Lstat(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and lstat is required to reject a symlink (FileIO has no Lstat; its Stat follows symlinks).
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return output.ErrValidation("--dir cannot be read: %v", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return output.ErrValidation("--dir must not be a symlink: %q", dir)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--dir exists and is not a directory: %q", dir)
|
||||
}
|
||||
entries, err := os.ReadDir(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and FileIO has no ReadDir.
|
||||
if err != nil {
|
||||
return output.ErrValidation("--dir cannot be read: %v", err)
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
return output.ErrValidation("target directory %q already exists and is not empty", dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAlreadyInitialized reports whether dir is an already-initialized Miaoda app
|
||||
// repo, detected by the presence of <dir>/.spark/meta.json (regardless of its
|
||||
// app_id value). Used to short-circuit +init into a friendly no-op.
|
||||
func isAlreadyInitialized(dir string) bool {
|
||||
info, err := os.Stat(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths.
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
|
||||
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
|
||||
// the file does not exist, this is a no-op (we never create it).
|
||||
func ensureMetaAppID(dir, appID string) error {
|
||||
path := filepath.Join(dir, metaRelPath)
|
||||
b, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "read %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "parse %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
if cur, _ := m["app_id"].(string); strings.TrimSpace(cur) != "" {
|
||||
return nil
|
||||
}
|
||||
if m == nil {
|
||||
m = map[string]interface{}{}
|
||||
}
|
||||
m["app_id"] = appID
|
||||
out, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "marshal %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Save rejects absolute paths.
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "write %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasSteeringSkills reports whether <dir>/.agent/skills/steering exists as a dir.
|
||||
func hasSteeringSkills(dir string) bool {
|
||||
info, err := os.Stat(filepath.Join(dir, steeringRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths.
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// isEmptyRepo reports whether the checked-out branch has no tracked files
|
||||
// other than the backend's default seed README.md. `git ls-files` listing
|
||||
// nothing — or only README.md — counts as empty (→ scaffold via `app init`).
|
||||
func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
|
||||
stdout, stderr, err := initRunner.Run(ctx, dir, "git", "ls-files")
|
||||
if err != nil {
|
||||
return false, output.Errorf(output.ExitAPI, "git_ls_files", "git ls-files failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
|
||||
f := strings.TrimSpace(line)
|
||||
// Match the seed exactly (case- and path-sensitive): only a root-level
|
||||
// "README.md" is the backend's default seed. A docs/README.md or readme.md
|
||||
// is treated as real content (→ non-empty), which is the safe direction
|
||||
// (skip scaffolding rather than risk overwriting). Extend this allow-list
|
||||
// here if the backend's seed set grows.
|
||||
if f == "" || f == seedReadme {
|
||||
continue
|
||||
}
|
||||
return false, nil // a non-README tracked file → non-empty repo
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// runScaffold runs the npx scaffolding step inside the cloned repo (cwd=dir).
|
||||
// Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch +
|
||||
// conditional `skills sync`. Returns "init" or "upgrade".
|
||||
func runScaffold(ctx context.Context, dir, appID, template string) (string, error) {
|
||||
empty, err := isEmptyRepo(ctx, dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if empty {
|
||||
// isEmptyRepo treats a repo with no tracked files — or only the backend's
|
||||
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
|
||||
// appear, extend isEmptyRepo's allow-list accordingly.
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_app_init", "npx app init failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
return scaffoldKindInit, nil
|
||||
}
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_app_sync", "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if err := ensureMetaAppID(dir, appID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasSteeringSkills(dir) {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_skills_sync", "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
}
|
||||
return scaffoldKindUpgrade, nil
|
||||
}
|
||||
|
||||
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
|
||||
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
|
||||
// matches the contract emitted by `apps +git-credential-init`.
|
||||
func parseRepoURLFromEnvelope(stdout string) (string, error) {
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "could not parse +git-credential-init output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init returned no repository_url")
|
||||
}
|
||||
return env.Data.RepositoryURL, nil
|
||||
}
|
||||
|
||||
// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success
|
||||
// envelope ({"ok":true,"data":{"env_file":"..."}}) on stdout.
|
||||
func parseEnvFileFromEnvelope(stdout string) (string, error) {
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
EnvFile string `json:"env_file"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "could not parse +env-pull output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.EnvFile) == "" {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull returned no env_file")
|
||||
}
|
||||
return env.Data.EnvFile, nil
|
||||
}
|
||||
|
||||
// parseEnvPullErrorEnvelope extracts a single-line reason from a `+env-pull`
|
||||
// error envelope ({"ok":false,"error":{"type":...,"message":...}}) on stderr.
|
||||
// Returns "" when stderr is not a parseable error envelope (caller falls back).
|
||||
func parseEnvPullErrorEnvelope(stderr string) string {
|
||||
var env struct {
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(stderr)), &env); err != nil {
|
||||
return ""
|
||||
}
|
||||
msg := strings.TrimSpace(env.Error.Message)
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
if t := strings.TrimSpace(env.Error.Type); t != "" {
|
||||
return t + ": " + msg
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// validateRepoURLScheme rejects any repository_url that is not http(s):// to
|
||||
// block git's dangerous transports (ext::, file://, ssh://) and option injection.
|
||||
func validateRepoURLScheme(repoURL string) error {
|
||||
if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") {
|
||||
return nil
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "validation",
|
||||
"repository_url from +git-credential-init must be http(s); refusing %q", redactURLCredentials(repoURL))
|
||||
}
|
||||
|
||||
func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
|
||||
dir, err := resolveTargetPath(rctx, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
// initialized Miaoda app repo -> skip clone/scaffold/commit, but still refresh
|
||||
// the local env so a re-run picks up the latest startup env vars.
|
||||
if isAlreadyInitialized(dir) {
|
||||
initLogf(rctx, "Already initialized at %s — refreshing local environment", dir)
|
||||
out := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"clone_path": dir,
|
||||
"scaffold": "already_initialized",
|
||||
"committed": false,
|
||||
"pushed": false,
|
||||
}
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
out["env_pulled"] = envPulled
|
||||
if envPulled {
|
||||
initLogf(rctx, "Local environment written to %s", envFile)
|
||||
out["env_file"] = envFile
|
||||
out["message"] = "Repository already initialized. Local env refreshed — you can start developing."
|
||||
} else {
|
||||
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
|
||||
out["env_pull_error"] = envPullErr
|
||||
out["message"] = fmt.Sprintf("Repository already initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Already initialized at %s\n", dir)
|
||||
if envPulled {
|
||||
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
|
||||
} else {
|
||||
fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr)
|
||||
fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID)
|
||||
}
|
||||
fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "dependency",
|
||||
"git executable not found on PATH", "install git and ensure it is on your PATH")
|
||||
}
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "dependency",
|
||||
"npx executable not found on PATH", "install Node.js (which provides npx) and ensure it is on your PATH")
|
||||
}
|
||||
|
||||
if err := ensureEmptyDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initLogf(rctx, "Issuing repository credentials for %s...", appID)
|
||||
repoURL, err := issueCredentials(ctx, rctx, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRepoURLScheme(repoURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initLogf(rctx, "Cloning into %s...", dir)
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_clone", "git clone failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
initLogf(rctx, "Checking out %s...", defaultInitBranch)
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "git", "checkout", defaultInitBranch); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_checkout", "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
}
|
||||
|
||||
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
|
||||
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
committed, pushed, err := commitAndPushIfDirty(ctx, dir, scaffold)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pushed {
|
||||
initLogf(rctx, "Committed and pushed to %s", defaultInitBranch)
|
||||
} else {
|
||||
initLogf(rctx, "Working tree clean — skipped commit/push")
|
||||
}
|
||||
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
if envPulled {
|
||||
initLogf(rctx, "Local environment written to %s", envFile)
|
||||
} else {
|
||||
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"repository_url": redactURLCredentials(repoURL),
|
||||
"branch": defaultInitBranch,
|
||||
"clone_path": dir,
|
||||
"scaffold": scaffold,
|
||||
"committed": committed,
|
||||
"pushed": pushed,
|
||||
"env_pulled": envPulled,
|
||||
"message": "Repository initialized. You can start developing.",
|
||||
}
|
||||
if envPulled {
|
||||
out["env_file"] = envFile
|
||||
} else {
|
||||
out["env_pull_error"] = envPullErr
|
||||
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir)
|
||||
fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold)
|
||||
if envPulled {
|
||||
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
|
||||
} else {
|
||||
fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr)
|
||||
fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID)
|
||||
}
|
||||
fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// pullEnv runs `<self> apps +env-pull --app-id <appID> --project-path <dir>
|
||||
// --format json`, forwarding --as when set. Returns (envFile, "") on success or
|
||||
// ("", reason) on failure. Non-fatal by contract: the caller logs a warning and
|
||||
// continues. The success envelope is read from stdout, the error envelope from
|
||||
// stderr (lark-cli writes structured errors to stderr; see cmd/root.go
|
||||
// handleRootError). The reason is always redacted.
|
||||
func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string) (envFile, reason string) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", redactURLCredentials(fmt.Sprintf("cannot locate lark-cli executable: %v", err))
|
||||
}
|
||||
args := []string{"apps", "+env-pull", "--app-id", appID, "--project-path", dir, "--format", "json"}
|
||||
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
|
||||
args = append(args, "--as", as)
|
||||
}
|
||||
stdout, stderr, runErr := initRunner.Run(ctx, "", self, args...)
|
||||
if runErr != nil {
|
||||
r := parseEnvPullErrorEnvelope(stderr)
|
||||
if r == "" {
|
||||
r = gitErr(stderr, runErr)
|
||||
}
|
||||
return "", redactURLCredentials(r)
|
||||
}
|
||||
envFile, perr := parseEnvFileFromEnvelope(stdout)
|
||||
if perr != nil {
|
||||
return "", redactURLCredentials(perr.Error())
|
||||
}
|
||||
return envFile, ""
|
||||
}
|
||||
|
||||
// issueCredentials runs `<self> apps +git-credential-init --app-id <id> --format json`
|
||||
// and returns the repo_url it reports. Forwards --as when set.
|
||||
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "internal", "cannot locate lark-cli executable: %v", err)
|
||||
}
|
||||
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
|
||||
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
|
||||
args = append(args, "--as", as)
|
||||
}
|
||||
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
|
||||
if err != nil {
|
||||
return "", output.ErrWithHint(output.ExitAPI, "credential_init",
|
||||
fmt.Sprintf("apps +git-credential-init failed: %s", gitErr(stderr, err)),
|
||||
"ensure apps +git-credential-init is available and you are logged in")
|
||||
}
|
||||
return parseRepoURLFromEnvelope(stdout)
|
||||
}
|
||||
|
||||
// commitAndPushIfDirty commits and pushes only when the working tree has
|
||||
// changes; a clean tree is a no-op (returns false,false). For the empty-repo
|
||||
// init path (scaffoldKind == "init") it splits the scaffolded tree into two
|
||||
// commits — app project code, then Miaoda config (.spark/.agent) — skipping
|
||||
// either commit when that group has no changes (no empty commits). Other paths
|
||||
// commit once. Push is a single `git push origin <branch>` for all commits.
|
||||
func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) {
|
||||
status, stderr, runErr := initRunner.Run(ctx, dir, "git", "status", "--porcelain")
|
||||
if runErr != nil {
|
||||
return false, false, output.Errorf(output.ExitAPI, "git_status", "git status failed: %s", gitErr(stderr, runErr))
|
||||
}
|
||||
if strings.TrimSpace(status) == "" {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
if scaffoldKind == scaffoldKindInit {
|
||||
// Stage each group by its exact porcelain paths (never gitignored files),
|
||||
// so neither `git add` errors on an ignored path like .agent.
|
||||
appPaths, configPaths := classifyPorcelain(status)
|
||||
if len(appPaths) > 0 {
|
||||
if e := stageAndCommit(ctx, dir, commitMsgAppCode, appPaths...); e != nil {
|
||||
return committed, false, e
|
||||
}
|
||||
committed = true
|
||||
}
|
||||
if len(configPaths) > 0 {
|
||||
if e := stageAndCommit(ctx, dir, commitMsgAppConfig, configPaths...); e != nil {
|
||||
return committed, false, e
|
||||
}
|
||||
committed = true
|
||||
}
|
||||
} else {
|
||||
if e := stageAndCommit(ctx, dir, commitMsgUpgrade, "."); e != nil {
|
||||
return false, false, e
|
||||
}
|
||||
committed = true
|
||||
}
|
||||
|
||||
if !committed {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil {
|
||||
return true, false, withAppsHint(
|
||||
output.Errorf(output.ExitAPI, "git_push", "git push failed: %s", gitErr(se, e)),
|
||||
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded")
|
||||
}
|
||||
return true, true, nil
|
||||
}
|
||||
|
||||
// stageAndCommit stages the given pathspecs (`git add -A -- <pathspecs>`) and
|
||||
// makes one `git commit --no-verify -m message`. --no-verify skips the scaffold
|
||||
// repo's local pre-commit / commit-msg hooks (local only; the later push is not
|
||||
// --no-verify). Callers gate this on classifyPorcelain so the group is non-empty
|
||||
// and the commit never hits "nothing to commit".
|
||||
func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...string) error {
|
||||
addArgs := append([]string{"add", "-A", "--"}, pathspecs...)
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", addArgs...); e != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_add", "git add failed: %s", gitErr(se, e))
|
||||
}
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_commit", "git commit failed: %s", gitErr(se, e))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyPorcelain parses `git status --porcelain` output and partitions the
|
||||
// changed paths into the "app code" group (anything outside .spark/ and .agent/)
|
||||
// and the "Miaoda config" group (.spark/ and .agent/). It returns the exact
|
||||
// porcelain paths so callers can stage them verbatim: porcelain never lists
|
||||
// gitignored files, so `git add -- <these paths>` never trips git's ignored-path
|
||||
// error. (Naming an ignored dir explicitly — or combining a "." pathspec with
|
||||
// :(exclude) magic — DOES error when a scaffold template gitignores e.g. .agent,
|
||||
// which is why we stage exact paths instead of pathspecs.)
|
||||
func classifyPorcelain(status string) (appPaths, configPaths []string) {
|
||||
for _, line := range strings.Split(status, "\n") {
|
||||
p := porcelainPath(line)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if isConfigPath(p) {
|
||||
configPaths = append(configPaths, p)
|
||||
} else {
|
||||
appPaths = append(appPaths, p)
|
||||
}
|
||||
}
|
||||
return appPaths, configPaths
|
||||
}
|
||||
|
||||
// porcelainPath extracts the path from a `git status --porcelain` v1 line.
|
||||
// Format is "XY <path>" (2 status chars + space); rename/copy lines are
|
||||
// "XY <orig> -> <dest>" (dest is what matters). Quoted paths are unquoted.
|
||||
func porcelainPath(line string) string {
|
||||
if len(line) < 4 {
|
||||
return ""
|
||||
}
|
||||
p := line[3:]
|
||||
if i := strings.Index(p, " -> "); i >= 0 {
|
||||
p = p[i+len(" -> "):]
|
||||
}
|
||||
p = strings.TrimSpace(p)
|
||||
p = strings.Trim(p, `"`)
|
||||
return p
|
||||
}
|
||||
|
||||
// isConfigPath reports whether p is the Miaoda app-config group: the .spark or
|
||||
// .agent directory itself, or anything under them. ".sparkrc" is NOT config.
|
||||
func isConfigPath(p string) bool {
|
||||
return p == ".spark" || p == ".agent" ||
|
||||
strings.HasPrefix(p, ".spark/") || strings.HasPrefix(p, ".agent/")
|
||||
}
|
||||
|
||||
// gitErr builds a redacted, single-line error detail from stderr (falling back
|
||||
// to the exec error). Always redacts embedded credentials.
|
||||
func gitErr(stderr string, err error) string {
|
||||
s := strings.TrimSpace(stderr)
|
||||
if s == "" && err != nil {
|
||||
s = err.Error()
|
||||
}
|
||||
return redactURLCredentials(s)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListyCommandsHaveJqTip(t *testing.T) {
|
||||
wantCmds := map[string]bool{
|
||||
"+list": true, "+db-table-list": true, "+db-table-schema": true,
|
||||
"+db-sql": true, "+release-list": true, "+session-list": true,
|
||||
}
|
||||
for _, s := range Shortcuts() {
|
||||
if !wantCmds[s.Command] {
|
||||
continue
|
||||
}
|
||||
has := false
|
||||
for _, tip := range s.Tips {
|
||||
if strings.Contains(tip, "--jq") || strings.Contains(tip, "-q '") {
|
||||
has = true
|
||||
}
|
||||
}
|
||||
if !has {
|
||||
t.Errorf("%s should have a --jq filter tip", s.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,30 +12,23 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsList lists Miaoda apps visible to the calling user (cursor pagination).
|
||||
// AppsList lists Miaoda apps owned by the calling user (cursor pagination).
|
||||
//
|
||||
// Supports name fuzzy match (--keyword), ownership-dimension filter
|
||||
// (--ownership: all / mine / shared), and app-type filter (--app-type). See
|
||||
// lark-apps SKILL.md for when an agent should use this to resolve an app_id
|
||||
// from a user-supplied name (only when the user named an app and a downstream
|
||||
// op needs its app_id — never unconditional enumeration).
|
||||
// Hidden from --help / tab completion (Hidden: true) so agents do not discover it
|
||||
// as a way to enumerate / search applications. Direct invocation still works for
|
||||
// humans who know the command. When agents need an existing app_id, they should
|
||||
// ask the user to provide either the Miaoda app URL (extract app_id from the
|
||||
// path segment after /app/) or the app_id string directly; see lark-apps SKILL.md.
|
||||
var AppsList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+list",
|
||||
Description: "List Miaoda apps visible to the calling user (cursor pagination)",
|
||||
Description: "List Miaoda apps owned by the calling user (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +list",
|
||||
"Example: lark-cli apps +list --keyword <keyword>",
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.items[].app_id'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Hidden: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Desc: "fuzzy match on app name"},
|
||||
{Name: "ownership", Desc: "ownership filter: all (created by me + shared with me) | mine | shared", Enum: []string{"all", "mine", "shared"}},
|
||||
{Name: "app-type", Desc: "app type filter (html or full_stack)", Enum: []string{"html", "full_stack"}},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
@@ -46,42 +39,18 @@ var AppsList = common.Shortcut{
|
||||
Params(buildAppsListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
|
||||
data, err := rctx.CallAPI("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Project away icon_url (an image URL agents can't render) and created_at
|
||||
// (redundant with updated_at) from every item BEFORE OutFormat, so json /
|
||||
// table / pretty are all lean. Every other field (description, etc.) is kept.
|
||||
rawItems, _ := data["items"].([]interface{})
|
||||
items := make([]interface{}, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
if k == "icon_url" || k == "created_at" {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
items = append(items, out)
|
||||
}
|
||||
data["items"] = items
|
||||
items, _ := data["items"].([]interface{})
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
// Curated pretty view (--format pretty) shows the columns most useful
|
||||
// for visual scanning: app_id (to copy-paste downstream), name (to match
|
||||
// what the user sees in the UI), is_published / online_url (publish state
|
||||
// and post-publish access link — the actionable fields after a deploy),
|
||||
// and updated_at (to pick the most recent variant). online_url can be long
|
||||
// but is the key value once published; the renderer clamps column width.
|
||||
// Unpublished apps carry no online_url, so that cell renders empty.
|
||||
// description stays in the underlying data (--format json / table) but
|
||||
// would make the curated view too wide. icon_url / created_at are trimmed
|
||||
// from the data entirely above (not useful to an agent).
|
||||
// Table view (--format table) intentionally shows only the columns
|
||||
// most useful for visual scanning: app_id (to copy-paste downstream),
|
||||
// name (to match what the user sees in the UI), and updated_at (to
|
||||
// pick the most recent variant). description / icon_url / created_at
|
||||
// stay in the underlying JSON (--format json) but would make the
|
||||
// table too wide for a terminal.
|
||||
rows := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
@@ -89,11 +58,9 @@ var AppsList = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"app_id": m["app_id"],
|
||||
"name": m["name"],
|
||||
"is_published": m["is_published"],
|
||||
"online_url": m["online_url"],
|
||||
"updated_at": m["updated_at"],
|
||||
"app_id": m["app_id"],
|
||||
"name": m["name"],
|
||||
"updated_at": m["updated_at"],
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
@@ -109,14 +76,5 @@ func buildAppsListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
if kw := strings.TrimSpace(rctx.Str("keyword")); kw != "" {
|
||||
params["keyword"] = kw
|
||||
}
|
||||
if ownership := strings.TrimSpace(rctx.Str("ownership")); ownership != "" {
|
||||
params["ownership"] = ownership
|
||||
}
|
||||
if at := strings.TrimSpace(rctx.Str("app-type")); at != "" {
|
||||
params["app_type"] = at
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -63,56 +63,6 @@ func TestAppsList_WithPageToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_WithKeywordOwnershipAppType(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps?app_type=html&keyword=%E9%97%AE%E5%8D%B7&ownership=mine&page_size=20",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--keyword", "问卷", "--ownership", "mine", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_InvalidOwnership(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--ownership", "bogus", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected enum validation error for --ownership bogus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_InvalidAppType(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--app-type", "HTML", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected enum validation error for --app-type HTML (hard cut to lowercase)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_DryRunWithFilters(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--keyword", "q", "--ownership", "all", "--app-type", "full_stack", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"keyword", "ownership", "app_type", "full_stack"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("dry-run missing %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
@@ -128,86 +78,3 @@ func TestAppsList_DryRun(t *testing.T) {
|
||||
t.Fatalf("dry-run missing page_size param: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_TrimsIconURLAndCreatedAt(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps?page_size=20",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"app_id": "app_x",
|
||||
"name": "Trim Me",
|
||||
"is_published": true,
|
||||
"online_url": "https://example.com/spark/faas/app_x",
|
||||
"updated_at": "2026-05-28T10:05:16Z",
|
||||
"created_at": "2026-05-01T08:00:00Z",
|
||||
"icon_url": "https://example.com/icon.png",
|
||||
"description": "An app to test trimming",
|
||||
},
|
||||
},
|
||||
"page_token": "next_cursor",
|
||||
"has_more": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsList, []string{"+list", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, drop := range []string{"icon_url", "created_at"} {
|
||||
if strings.Contains(got, drop) {
|
||||
t.Fatalf("default output should not contain %q:\n%s", drop, got)
|
||||
}
|
||||
}
|
||||
for _, keep := range []string{"app_id", "name", "is_published", "online_url", "updated_at", "description"} {
|
||||
if !strings.Contains(got, keep) {
|
||||
t.Fatalf("default output missing %q:\n%s", keep, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_PrettyShowsPublishFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps?page_size=20",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"app_id": "app_pub",
|
||||
"name": "Published App",
|
||||
"is_published": true,
|
||||
"online_url": "https://example.com/spark/faas/app_pub",
|
||||
"updated_at": "2026-05-28T10:05:16Z",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"app_id": "app_draft",
|
||||
"name": "Draft App",
|
||||
"is_published": false,
|
||||
"updated_at": "2026-05-31T12:31:27Z",
|
||||
},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"is_published", "online_url", "https://example.com/spark/faas/app_pub", "true", "false"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// Gateway paths for the spark app.release OpenAPI methods.
|
||||
// Prefix reuses apiBasePath = "/open-apis/spark/v1" (same package).
|
||||
// Each path contains %s placeholders; use fmt.Sprintf to build the final URL.
|
||||
const (
|
||||
releaseCreatePath = apiBasePath + "/apps/%s/releases"
|
||||
releaseGetPath = apiBasePath + "/apps/%s/releases/%s"
|
||||
releaseListPath = apiBasePath + "/apps/%s/releases"
|
||||
)
|
||||
|
||||
// writeReleaseErrorLogTable renders a release's error_logs (a slice of
|
||||
// {step, error_log} maps from the gateway) as a two-column step/error_log
|
||||
// table via output.PrintTable. Used by +release-get to render a failed
|
||||
// release's error_logs. A nil/non-slice or
|
||||
// empty value yields an empty table (PrintTable prints "(no data)").
|
||||
func writeReleaseErrorLogTable(w io.Writer, raw interface{}) {
|
||||
logs, _ := raw.([]interface{})
|
||||
rows := make([]map[string]interface{}, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
m, ok := l.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"step": m["step"],
|
||||
"error_log": m["error_log"],
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsReleaseCreate creates a release for a Miaoda app.
|
||||
var AppsReleaseCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+release-create",
|
||||
Description: "Create a release for a Miaoda app (returns release_id for status polling)",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +release-create --app-id <app_id>",
|
||||
"Example: lark-cli apps +release-create --app-id <app_id> --branch sprint/default --dry-run",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
|
||||
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
branch := strings.TrimSpace(rctx.Str("branch"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Create a release").
|
||||
Body(buildPublishBody(branch))
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
branch := strings.TrimSpace(rctx.Str("branch"))
|
||||
path := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, buildPublishBody(branch))
|
||||
if err != nil {
|
||||
return withAppsHint(err, "if the push was rejected (non-fast-forward), sync first with `git pull --rebase origin sprint/default` then retry; inspect the failure via `lark-cli apps +release-get --app-id "+appID+" --release-id <release_id>`")
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"release_id": common.GetString(data, "release_id"),
|
||||
"status": common.GetString(data, "status"),
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "release_id: %s\nstatus: %s\n", out["release_id"], out["status"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildPublishBody builds the create-release request body. app_id is in the
|
||||
// path, not the body. branch is included only when non-empty.
|
||||
func buildPublishBody(branch string) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if branch != "" {
|
||||
body["branch"] = branch
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBuildPublishBody(t *testing.T) {
|
||||
// branch included when non-empty; app_id is NOT in body (it's in the path)
|
||||
b := buildPublishBody("feat/devops")
|
||||
if b["branch"] != "feat/devops" {
|
||||
t.Errorf("body = %v", b)
|
||||
}
|
||||
if _, ok := b["app_id"]; ok {
|
||||
t.Errorf("app_id must not be in body, got %v", b)
|
||||
}
|
||||
// branch omitted when empty
|
||||
b2 := buildPublishBody("")
|
||||
if _, ok := b2["branch"]; ok {
|
||||
t.Errorf("branch should be omitted when empty, got %v", b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseCreateMeta(t *testing.T) {
|
||||
if AppsReleaseCreate.Command != "+release-create" || AppsReleaseCreate.Risk != "write" {
|
||||
t.Errorf("meta mismatch: %+v", AppsReleaseCreate)
|
||||
}
|
||||
if len(AppsReleaseCreate.Scopes) != 1 || AppsReleaseCreate.Scopes[0] != "spark:app:write" {
|
||||
t.Errorf("scopes = %v", AppsReleaseCreate.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
// newReleaseCreateRuntimeContext builds a RuntimeContext whose cobra.Command has the
|
||||
// flags that AppsReleaseCreate.Execute reads (app-id, branch). Flag values are set
|
||||
// via the returned setter helper.
|
||||
func newReleaseCreateRuntimeContext(t *testing.T, appID, branch string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
cmd := &cobra.Command{Use: "test-release-create"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.Flags().String("app-id", "", "")
|
||||
cmd.Flags().String("branch", "", "")
|
||||
_ = cmd.Flags().Set("app-id", appID)
|
||||
if branch != "" {
|
||||
_ = cmd.Flags().Set("branch", branch)
|
||||
}
|
||||
|
||||
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
|
||||
return rctx, stdoutBuf, reg
|
||||
}
|
||||
|
||||
func TestAppsReleaseCreateExecute_Success(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newReleaseCreateRuntimeContext(t, "app_x", "main")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release_id": "123",
|
||||
"status": "publishing",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := AppsReleaseCreate.Execute(context.Background(), rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
|
||||
}
|
||||
if env.Data["release_id"] != "123" {
|
||||
t.Errorf("release_id = %v, want 123", env.Data["release_id"])
|
||||
}
|
||||
if env.Data["status"] != "publishing" {
|
||||
t.Errorf("status = %v, want publishing", env.Data["status"])
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsReleaseGet fetches a single release's detail by release ID.
|
||||
var AppsReleaseGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+release-get",
|
||||
Description: "Get a single release's status/detail by release ID",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +release-get --app-id <app_id> --release-id <release_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
|
||||
{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("release-id")) == "" {
|
||||
return output.ErrValidation("--release-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
releaseID := strings.TrimSpace(rctx.Str("release-id"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.GET(fmt.Sprintf(releaseGetPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(releaseID))).
|
||||
Desc("Get release detail")
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
releaseID := strings.TrimSpace(rctx.Str("release-id"))
|
||||
path := fmt.Sprintf(releaseGetPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(releaseID))
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, "if the release_id is unknown or invalid, list this app's releases with `lark-cli apps +release-list --app-id "+appID+"`")
|
||||
}
|
||||
out := data
|
||||
if release, ok := data["release"].(map[string]interface{}); ok {
|
||||
out = release
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "release_id: %v\nstatus: %v\ncreated_at: %v\nupdated_at: %v\n",
|
||||
out["release_id"], out["status"], out["created_at"], out["updated_at"])
|
||||
if commitID, ok := out["commit_id"].(string); ok && commitID != "" {
|
||||
fmt.Fprintf(w, "commit_id: %s\n", commitID)
|
||||
}
|
||||
status, _ := out["status"].(string)
|
||||
switch status {
|
||||
case "finished":
|
||||
if url, ok := out["online_url"].(string); ok && url != "" {
|
||||
fmt.Fprintf(w, "online_url: %s\n", url)
|
||||
}
|
||||
case "failed":
|
||||
writeReleaseErrorLogTable(w, out["error_logs"])
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestAppsReleaseGetMeta(t *testing.T) {
|
||||
if AppsReleaseGet.Command != "+release-get" || AppsReleaseGet.Risk != "read" {
|
||||
t.Errorf("meta mismatch: %+v", AppsReleaseGet)
|
||||
}
|
||||
if len(AppsReleaseGet.Scopes) != 1 || AppsReleaseGet.Scopes[0] != "spark:app:read" {
|
||||
t.Errorf("scopes = %v", AppsReleaseGet.Scopes)
|
||||
}
|
||||
// both --app-id and --release-id must be required
|
||||
req := map[string]bool{}
|
||||
for _, f := range AppsReleaseGet.Flags {
|
||||
req[f.Name] = f.Required
|
||||
}
|
||||
if !req["app-id"] || !req["release-id"] {
|
||||
t.Errorf("app-id and release-id must be Required; flags=%+v", AppsReleaseGet.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
// newStatusRuntimeContext builds a RuntimeContext for AppsReleaseGet.Execute tests.
|
||||
func newStatusRuntimeContext(t *testing.T, appID, releaseID string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
cmd := &cobra.Command{Use: "test-release-get"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.Flags().String("app-id", "", "")
|
||||
cmd.Flags().String("release-id", "", "")
|
||||
_ = cmd.Flags().Set("app-id", appID)
|
||||
_ = cmd.Flags().Set("release-id", releaseID)
|
||||
|
||||
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
|
||||
return rctx, stdoutBuf, reg
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetExecute_Success(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases/5",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release": map[string]interface{}{
|
||||
"release_id": "5",
|
||||
"status": "finished",
|
||||
"created_at": "1700000000000",
|
||||
"updated_at": "1700000000001",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := AppsReleaseGet.Execute(context.Background(), rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
|
||||
}
|
||||
// Execute unwraps the nested "release" object
|
||||
if env.Data["release_id"] != "5" {
|
||||
t.Errorf("release_id = %v, want 5", env.Data["release_id"])
|
||||
}
|
||||
if env.Data["status"] != "finished" {
|
||||
t.Errorf("status = %v, want finished", env.Data["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyFinishedOnlineURL(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases/5",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "5", "status": "finished",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000001",
|
||||
"online_url": "https://example.feishu.cn/spark/faas/app_x",
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if !strings.Contains(out, "status: finished") {
|
||||
t.Errorf("missing base fields:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "online_url: https://example.feishu.cn/spark/faas/app_x") {
|
||||
t.Errorf("expected online_url line, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyFailedErrorLogs(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "6")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases/6",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "6", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
"error_logs": []interface{}{
|
||||
map[string]interface{}{"step": "build", "error_log": "compile error"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if !strings.Contains(out, "status: failed") {
|
||||
t.Errorf("missing base fields:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "build") || !strings.Contains(out, "compile error") {
|
||||
t.Errorf("expected error_logs table with step/error_log, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyPublishingNoExtra(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "7")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/7",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "7", "status": "publishing",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000000",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if strings.Contains(out, "online_url:") || strings.Contains(out, "error_log") {
|
||||
t.Errorf("publishing must not add extra fields, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyFinishedNoURL(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "8")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/8",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "8", "status": "finished",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000001",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if strings.Contains(stdoutBuf.String(), "online_url:") {
|
||||
t.Errorf("finished without online_url must not print the line, got:\n%s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "9")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/9",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "9", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
"error_logs": []interface{}{},
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if strings.Contains(stdoutBuf.String(), "compile error") {
|
||||
t.Errorf("empty error_logs must not render row content, got:\n%s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyCommitID(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "10")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/10",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "10", "status": "publishing",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000000",
|
||||
"commit_id": "1230aisdkjah9123913hi193",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "commit_id: 1230aisdkjah9123913hi193") {
|
||||
t.Errorf("expected commit_id line, got:\n%s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyNoCommitID(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "11")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/11",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "11", "status": "publishing",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000000",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if strings.Contains(stdoutBuf.String(), "commit_id:") {
|
||||
t.Errorf("absent commit_id must not print commit_id line, got:\n%s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyEmptyCommitID(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "12")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/12",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "12", "status": "publishing",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000000",
|
||||
"commit_id": "",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if strings.Contains(stdoutBuf.String(), "commit_id:") {
|
||||
t.Errorf("empty commit_id must not print commit_id line, got:\n%s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetJSONOnlineURLPassthrough(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/5",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "5", "status": "finished",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000001",
|
||||
"online_url": "https://example.feishu.cn/spark/faas/app_x",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if env.Data["online_url"] != "https://example.feishu.cn/spark/faas/app_x" {
|
||||
t.Errorf("JSON must passthrough online_url, got: %v", env.Data["online_url"])
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsReleaseList lists a Miaoda app's release history (most recent first).
|
||||
var AppsReleaseList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+release-list",
|
||||
Description: "List a Miaoda app's release history (most recent first)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +release-list --app-id <app_id>",
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.releases[].release_id'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
|
||||
{Name: "status", Enum: []string{"publishing", "finished", "failed"}, Desc: "filter by release status: publishing | finished | failed"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (max 500)"},
|
||||
{Name: "page-token", Desc: "pagination cursor from a previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
status := strings.TrimSpace(rctx.Str("status"))
|
||||
pageSize := rctx.Int("page-size")
|
||||
pageToken := strings.TrimSpace(rctx.Str("page-token"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.GET(fmt.Sprintf(releaseListPath, validate.EncodePathSegment(appID))).
|
||||
Desc("List release history").
|
||||
Params(buildReleaseListQuery(status, pageSize, pageToken))
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
status := strings.TrimSpace(rctx.Str("status"))
|
||||
pageSize := rctx.Int("page-size")
|
||||
pageToken := strings.TrimSpace(rctx.Str("page-token"))
|
||||
path := fmt.Sprintf(releaseListPath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("GET", path, buildReleaseListQuery(status, pageSize, pageToken), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
releases, _ := data["releases"].([]interface{})
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
rows := make([]map[string]interface{}, 0, len(releases))
|
||||
for _, it := range releases {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"release_id": m["release_id"],
|
||||
"status": m["status"],
|
||||
"created_at": m["created_at"],
|
||||
"updated_at": m["updated_at"],
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildReleaseListQuery builds the list-releases query parameters. app_id is in
|
||||
// the path. page_size is always sent; status and page_token (snake) are included
|
||||
// only when non-empty.
|
||||
func buildReleaseListQuery(status string, pageSize int, pageToken string) map[string]interface{} {
|
||||
q := map[string]interface{}{
|
||||
"page_size": pageSize,
|
||||
}
|
||||
if status != "" {
|
||||
q["status"] = status
|
||||
}
|
||||
if pageToken != "" {
|
||||
q["page_token"] = pageToken
|
||||
}
|
||||
return q
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBuildReleaseListQuery(t *testing.T) {
|
||||
// page_size always present; status/page_token omitted when empty; app_id is in the path
|
||||
q := buildReleaseListQuery("", 0, "")
|
||||
if q["page_size"] != 0 {
|
||||
t.Errorf("page_size should always be present, got %v", q)
|
||||
}
|
||||
if _, ok := q["status"]; ok {
|
||||
t.Errorf("status should be omitted when empty, got %v", q)
|
||||
}
|
||||
if _, ok := q["page_token"]; ok {
|
||||
t.Errorf("page_token should be omitted when empty, got %v", q)
|
||||
}
|
||||
q2 := buildReleaseListQuery("finished", 30, "tok")
|
||||
if q2["page_size"] != 30 {
|
||||
t.Errorf("page_size = %v, want 30", q2["page_size"])
|
||||
}
|
||||
if q2["status"] != "finished" {
|
||||
t.Errorf("status = %v, want finished", q2["status"])
|
||||
}
|
||||
if q2["page_token"] != "tok" {
|
||||
t.Errorf("page_token = %v, want tok", q2["page_token"])
|
||||
}
|
||||
if _, ok := q2["app_id"]; ok {
|
||||
t.Errorf("app_id must not be in query params, got %v", q2)
|
||||
}
|
||||
}
|
||||
|
||||
// newReleaseListRuntimeContext builds a RuntimeContext for AppsReleaseList.Execute tests.
|
||||
func newReleaseListRuntimeContext(t *testing.T, appID string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
cmd := &cobra.Command{Use: "test-release-list"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.Flags().String("app-id", "", "")
|
||||
cmd.Flags().String("status", "", "")
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
_ = cmd.Flags().Set("app-id", appID)
|
||||
|
||||
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
|
||||
return rctx, stdoutBuf, reg
|
||||
}
|
||||
|
||||
func TestAppsReleaseListExecute_Success(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newReleaseListRuntimeContext(t, "app_x")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"releases": []interface{}{
|
||||
map[string]interface{}{
|
||||
"release_id": "1",
|
||||
"status": "finished",
|
||||
"created_at": "1700000000000",
|
||||
"updated_at": "1700000000000",
|
||||
},
|
||||
},
|
||||
"next_page_token": "tok",
|
||||
"has_more": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := AppsReleaseList.Execute(context.Background(), rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
|
||||
}
|
||||
|
||||
// releases passthrough
|
||||
releases, ok := env.Data["releases"].([]interface{})
|
||||
if !ok || len(releases) != 1 {
|
||||
t.Fatalf("releases = %v", env.Data["releases"])
|
||||
}
|
||||
r0 := releases[0].(map[string]interface{})
|
||||
if r0["release_id"] != "1" {
|
||||
t.Errorf("releases[0].release_id = %v, want 1", r0["release_id"])
|
||||
}
|
||||
if r0["status"] != "finished" {
|
||||
t.Errorf("releases[0].status = %v, want finished", r0["status"])
|
||||
}
|
||||
|
||||
// pagination fields passthrough
|
||||
if env.Data["next_page_token"] != "tok" {
|
||||
t.Errorf("next_page_token = %v, want tok", env.Data["next_page_token"])
|
||||
}
|
||||
if env.Data["has_more"] != true {
|
||||
t.Errorf("has_more = %v, want true", env.Data["has_more"])
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsSessionCreate creates a new session under an existing Miaoda app.
|
||||
var AppsSessionCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+session-create",
|
||||
Description: "Create a session under a Miaoda app",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +session-create --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST(sessionsPath(rctx.Str("app-id"))).
|
||||
Desc("Create a session under a Miaoda app")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("POST", sessionsPath(rctx.Str("app-id")), nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "session created: %s\n", common.GetString(data, "session_id"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// sessionsPath builds the collection path for an app's sessions.
|
||||
func sessionsPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/sessions", apiBasePath, validate.EncodePathSegment(strings.TrimSpace(appID)))
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsSessionCreate_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"session_id": "conv_new"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsSessionCreate,
|
||||
[]string{"+session-create", "--app-id", "app_x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"session_id": "conv_new"`) {
|
||||
t.Fatalf("stdout missing session_id: %s", got)
|
||||
}
|
||||
if len(stub.CapturedBody) != 0 {
|
||||
t.Fatalf("+session-create must POST with no body, got: %s", stub.CapturedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionCreate_Pretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"session_id": "conv_new"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsSessionCreate,
|
||||
[]string{"+session-create", "--app-id", "app_x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "session created: conv_new") {
|
||||
t.Fatalf("pretty output wrong: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionCreate_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// present-but-blank --app-id passes cobra MarkFlagRequired, caught by Validate hook.
|
||||
err := runAppsShortcut(t, AppsSessionCreate, []string{"+session-create", "--app-id", "", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "app-id") {
|
||||
t.Fatalf("expected --app-id required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionCreate_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionCreate,
|
||||
[]string{"+session-create", "--app-id", "app_x", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionCreate_EncodesAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionCreate,
|
||||
[]string{"+session-create", "--app-id", "a/b", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "apps/a/b/sessions") {
|
||||
t.Fatalf("app_id must be path-encoded, got raw slash: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsSessionGet reads a session's current status, queued turns, and latest turn.
|
||||
// Single-shot: the caller drives polling using next_poll_after_ms.
|
||||
var AppsSessionGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+session-get",
|
||||
Description: "Read a session's current status, queued turns, and latest turn",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +session-get --app-id <app_id> --session-id <session_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "session-id", Desc: "session ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET(sessionPath(rctx.Str("app-id"), rctx.Str("session-id"))).
|
||||
Desc("Read a session's status")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("GET", sessionPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, "if the session_id is unknown or invalid, list this app's sessions with `lark-cli apps +session-list --app-id "+strings.TrimSpace(rctx.Str("app-id"))+"`")
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "session: %s\n", common.GetString(data, "session_id"))
|
||||
fmt.Fprintf(w, "active: %v streaming: %v\n", data["is_active"], data["is_streaming"])
|
||||
if lt, ok := data["latest_turn"].(map[string]interface{}); ok {
|
||||
fmt.Fprintf(w, "latest turn: %v (%v)\n", lt["turn_id"], lt["status"])
|
||||
}
|
||||
fmt.Fprintf(w, "queued: %v\n", data["queued_count"])
|
||||
fmt.Fprintf(w, "next poll after: %vms\n", data["next_poll_after_ms"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// sessionPath builds the single-session path under an app. Defined here (first
|
||||
// consumer) so it never sits unused. Reused by Task 4 (+session-stop) and Task 5 (+chat).
|
||||
func sessionPath(appID, sessionID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/sessions/%s",
|
||||
apiBasePath,
|
||||
validate.EncodePathSegment(strings.TrimSpace(appID)),
|
||||
validate.EncodePathSegment(strings.TrimSpace(sessionID)))
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func sessionGetStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"session_id": "conv_x",
|
||||
"is_active": true,
|
||||
"is_streaming": true,
|
||||
"summary": "正在补充...",
|
||||
"queued_count": 1,
|
||||
"latest_turn": map[string]interface{}{"turn_id": "8421374923", "status": "running"},
|
||||
"next_poll_after_ms": 30000,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionGet_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(sessionGetStub())
|
||||
if err := runAppsShortcut(t, AppsSessionGet,
|
||||
[]string{"+session-get", "--app-id", "app_x", "--session-id", "conv_x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"is_streaming": true`) {
|
||||
t.Fatalf("stdout missing is_streaming: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionGet_PrettyReadsNestedSnakeCase(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(sessionGetStub())
|
||||
if err := runAppsShortcut(t, AppsSessionGet,
|
||||
[]string{"+session-get", "--app-id", "app_x", "--session-id", "conv_x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "8421374923") || !strings.Contains(got, "running") {
|
||||
t.Fatalf("pretty must read latest_turn.turn_id/status: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "30000") {
|
||||
t.Fatalf("pretty must show next_poll_after_ms: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionGet_RequiresFlags(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsSessionGet, []string{"+session-get", "--app-id", "app_x", "--session-id", "", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "session-id") {
|
||||
t.Fatalf("expected --session-id required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionGet_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionGet,
|
||||
[]string{"+session-get", "--app-id", "app_x", "--session-id", "conv_x", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsSessionList lists sessions under a Miaoda app (cursor pagination, single page).
|
||||
var AppsSessionList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+session-list",
|
||||
Description: "List sessions under a Miaoda app (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +session-list --app-id <app_id>",
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.sessions[].session_id'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (max 50)"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET(sessionsPath(rctx.Str("app-id"))).
|
||||
Desc("List sessions under a Miaoda app").
|
||||
Params(buildSessionListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("GET", sessionsPath(rctx.Str("app-id")), buildSessionListParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
sessions, _ := data["sessions"].([]interface{})
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
rows := make([]map[string]interface{}, 0, len(sessions))
|
||||
for _, item := range sessions {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"session_id": m["session_id"],
|
||||
"name": m["name"],
|
||||
"is_active": m["is_active"],
|
||||
"updated_at": m["updated_at"],
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildSessionListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsSessionList_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"sessions": []interface{}{
|
||||
map[string]interface{}{
|
||||
"session_id": "conv_a", "name": "建后台", "is_active": true,
|
||||
"created_at": "2026-05-28T10:00:00Z", "updated_at": "2026-05-28T11:00:00Z",
|
||||
},
|
||||
},
|
||||
"next_page_token": "",
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsSessionList,
|
||||
[]string{"+session-list", "--app-id", "app_x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"session_id": "conv_a"`) {
|
||||
t.Fatalf("stdout missing session: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionList_TableShowsKeyColumns(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"sessions": []interface{}{
|
||||
map[string]interface{}{"session_id": "conv_a", "name": "建后台", "is_active": true, "updated_at": "2026-05-28T11:00:00Z"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsSessionList,
|
||||
[]string{"+session-list", "--app-id", "app_x", "--format", "table", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "conv_a") || !strings.Contains(got, "建后台") {
|
||||
t.Fatalf("table missing key columns: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionList_PassesPagination(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionList,
|
||||
[]string{"+session-list", "--app-id", "app_x", "--page-size", "50", "--page-token", "tok1", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "page_size") || !strings.Contains(got, "50") {
|
||||
t.Fatalf("dry-run missing page_size: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "tok1") {
|
||||
t.Fatalf("dry-run missing page_token: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionList_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsSessionList, []string{"+session-list", "--app-id", "", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "app-id") {
|
||||
t.Fatalf("expected --app-id required error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const sessionStopHint = "verify --app-id and --session-id are correct (list sessions with `lark-cli apps +session-list --app-id <app_id>`); --turn-id must be the latest turn from `lark-cli apps +session-get --app-id <app_id> --session-id <session_id>`"
|
||||
|
||||
// AppsSessionStop interrupts the RUNNING turn of a session. No-op if the turn
|
||||
// is queued or already finished. Does not close the session.
|
||||
var AppsSessionStop = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+session-stop",
|
||||
Description: "Stop (interrupt) the running turn of a session",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +session-stop --app-id <app_id> --session-id <session_id> --turn-id <turn_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "session-id", Desc: "session ID", Required: true},
|
||||
{Name: "turn-id", Desc: "turn ID to stop (from +session-get latest_turn.turn_id)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("turn-id")) == "" {
|
||||
return output.ErrValidation("--turn-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST(stopPath(rctx.Str("app-id"), rctx.Str("session-id"))).
|
||||
Desc("Stop the running turn of a session").
|
||||
Body(buildStopBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("POST", stopPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, buildStopBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, sessionStopHint)
|
||||
}
|
||||
turnID := strings.TrimSpace(rctx.Str("turn-id"))
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
stopped, _ := data["stopped"].(bool)
|
||||
if stopped {
|
||||
fmt.Fprintf(w, "stopped turn %s. %v\n", turnID, data["message"])
|
||||
} else {
|
||||
fmt.Fprintf(w, "no-op: turn %s not stopped. %v\n", turnID, data["message"])
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func stopPath(appID, sessionID string) string {
|
||||
return sessionPath(appID, sessionID) + "/stop"
|
||||
}
|
||||
|
||||
func buildStopBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"turn_id": strings.TrimSpace(rctx.Str("turn-id")),
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsSessionStop_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"stopped": true, "message": "running turn stopped"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "8421374923", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["turn_id"] != "8421374923" {
|
||||
t.Fatalf("body.turn_id = %v", sent["turn_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionStop_PrettyStopped(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"stopped": true, "message": "running turn stopped"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "8421374923", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "stopped turn 8421374923") {
|
||||
t.Fatalf("pretty stopped wrong: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionStop_PrettyNoOp(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"stopped": false, "message": "turn already completed"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "t1", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "no-op") || !strings.Contains(got, "completed") {
|
||||
t.Fatalf("pretty no-op wrong: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionStop_RequiresTurnID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "turn-id") {
|
||||
t.Fatalf("expected --turn-id required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionStop_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "t1", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"turn_id": "t1"`) {
|
||||
t.Fatalf("dry-run missing turn_id body: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Encoding safeguard for the shared sessionPath helper (reused from Task 3).
|
||||
func TestAppsSessionStop_EncodesPathSegments(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "a/b", "--session-id", "c/d", "--turn-id", "t1", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if strings.Contains(got, "apps/a/b/sessions") || strings.Contains(got, "sessions/c/d/stop") {
|
||||
t.Fatalf("path segments must be encoded, got raw slash: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// frameworkGlobalFlags are injected by shortcuts/common/runner.go for every (or
|
||||
// many) shortcuts, so they are always allowed in skill docs regardless of which
|
||||
// command they are attached to. See registerShortcutFlagsWithContext in
|
||||
// shortcuts/common/runner.go: --dry-run, --format, --json, --jq/-q are injected
|
||||
// unconditionally; --as via the identity flag; --yes for high-risk-write;
|
||||
// --print-schema/--flag-name for shortcuts that opt into schema introspection;
|
||||
// --help/-h are cobra built-ins.
|
||||
var frameworkGlobalFlags = map[string]bool{
|
||||
"dry-run": true, "format": true, "json": true, "yes": true,
|
||||
"jq": true, "q": true, "as": true,
|
||||
"print-schema": true, "flag-name": true, "help": true, "h": true,
|
||||
}
|
||||
|
||||
// cmdRef is one apps command invocation extracted from a skill doc.
|
||||
type cmdRef struct {
|
||||
cmd string // registered command form, includes the leading '+'
|
||||
flags []string // long flag names without '--', short flags without '-'
|
||||
}
|
||||
|
||||
var (
|
||||
// cmdTokenRe matches a shortcut command token. The leading '+' is the
|
||||
// reliable signal; the body is a-z plus digits/hyphens. A real command
|
||||
// never ends in '-', so a trailing hyphen (from a glob like `+db-*`) is
|
||||
// stripped/rejected separately.
|
||||
cmdTokenRe = regexp.MustCompile(`\+[a-z][a-z0-9-]*`)
|
||||
longFlagRe = regexp.MustCompile(`^--([a-z][a-z0-9-]*)`)
|
||||
shortFlagRe = regexp.MustCompile(`^-([a-z])$`)
|
||||
// bareWordRe matches a plain lowercase word (a CLI service/qualifier token
|
||||
// like "apps", "contact", "im", "code") with no markdown decoration.
|
||||
bareWordRe = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
)
|
||||
|
||||
// documentedNonexistentExamples are apps-prefixed command tokens the lark-apps
|
||||
// docs deliberately cite as NOT being apps commands (negative examples that
|
||||
// warn agents away from inventing them). They are intentionally absent from
|
||||
// Shortcuts(); excluding them here is narrow and explicit, unlike skipping any
|
||||
// line containing "不存在" (a common Chinese word meaning "does not exist")
|
||||
// which would mask real drift on unrelated lines.
|
||||
//
|
||||
// Source lines (both files carry the identical sentence):
|
||||
// - skills/lark-apps/references/lark-apps-local-dev.md:52
|
||||
// - skills/lark-apps/references/lark-apps-git-credential.md:35
|
||||
// "...不存在 `apps +pull` / `apps +push` / `apps code +read` 这类...shortcut..."
|
||||
//
|
||||
// Only `+pull` and `+push` are apps-prefixed and thus need an explicit entry
|
||||
// here. `apps code +read` is preceded by the bare qualifier word "code" (not
|
||||
// "apps"), so the cross-service filter already rejects it; adding it would be
|
||||
// redundant double-coverage, so it is intentionally omitted.
|
||||
var documentedNonexistentExamples = map[string]bool{
|
||||
"+pull": true,
|
||||
"+push": true,
|
||||
}
|
||||
|
||||
// extractCmdRefs joins backslash-continued lines, then for each `+<cmd>` token
|
||||
// captures the --flags/-q that follow it, stopping at the next `+<cmd>` token, a
|
||||
// shell separator (| && ;), or the end of the inline-code span the command
|
||||
// appears in. Flags only attach within the same backtick-delimited segment as
|
||||
// the command, because skill docs write a real invocation inside one code span
|
||||
// (`lark-cli apps +create --name x`) while a stray `--flag` discussed in prose
|
||||
// (e.g. "`+git-credential-list` ... 不需要 `--app-id`") lives in a separate
|
||||
// span and must not attach.
|
||||
//
|
||||
// To avoid false positives it also:
|
||||
// - skips a `+token` immediately preceded by a bare service/qualifier word
|
||||
// other than "apps" (e.g. `contact +search-user`, `im +chat-search`,
|
||||
// `apps code +read`) — those are not apps shortcuts;
|
||||
// - rejects a token that ends in '-' (a wildcard family like `+db-*`,
|
||||
// `+release-*`), since no registered command ends in a hyphen.
|
||||
//
|
||||
// Deliberate negative examples (documentedNonexistentExamples, e.g. `+pull`)
|
||||
// are still extracted here; the consistency gate skips them explicitly when an
|
||||
// unregistered command turns out to be one of those documented examples.
|
||||
func extractCmdRefs(doc string) []cmdRef {
|
||||
var refs []cmdRef
|
||||
for _, logical := range logicalLines(doc) {
|
||||
// Split the logical line into backtick-delimited segments. A command and
|
||||
// its flags only travel together within one segment; crossing a backtick
|
||||
// boundary resets the capture context. Code-block lines (no backticks)
|
||||
// are a single segment and behave like a normal command line.
|
||||
var cur *cmdRef
|
||||
var prevClean string
|
||||
for _, seg := range strings.Split(logical, "`") {
|
||||
cur = nil // a new inline span never inherits the previous command
|
||||
prevClean = ""
|
||||
for _, tok := range strings.Fields(seg) {
|
||||
clean := strings.Trim(tok, ",'\"()*")
|
||||
if tok == "|" || tok == "&&" || tok == ";" {
|
||||
cur = nil
|
||||
prevClean = clean
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(clean, "+") {
|
||||
m := cmdTokenRe.FindString(clean)
|
||||
if m == "" || strings.HasSuffix(m, "-") {
|
||||
// Not a real command shape (e.g. "+1") or a wildcard
|
||||
// family like "+db-" from `+db-*`. No capture context.
|
||||
cur = nil
|
||||
prevClean = clean
|
||||
continue
|
||||
}
|
||||
// Cross-service reference: nearest preceding bare word is a
|
||||
// service/qualifier other than "apps".
|
||||
if bareWordRe.MatchString(prevClean) && prevClean != "apps" && prevClean != "lark-cli" {
|
||||
cur = nil
|
||||
prevClean = clean
|
||||
continue
|
||||
}
|
||||
refs = append(refs, cmdRef{cmd: m})
|
||||
cur = &refs[len(refs)-1]
|
||||
prevClean = clean
|
||||
continue
|
||||
}
|
||||
if cur != nil {
|
||||
if m := longFlagRe.FindStringSubmatch(clean); m != nil {
|
||||
cur.flags = append(cur.flags, m[1])
|
||||
} else if m := shortFlagRe.FindStringSubmatch(clean); m != nil {
|
||||
cur.flags = append(cur.flags, m[1])
|
||||
}
|
||||
}
|
||||
prevClean = clean
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// logicalLines merges lines ending with a backslash into one logical line.
|
||||
func logicalLines(doc string) []string {
|
||||
raw := strings.Split(strings.ReplaceAll(doc, "\r\n", "\n"), "\n")
|
||||
var out []string
|
||||
var buf strings.Builder
|
||||
carrying := false
|
||||
for _, ln := range raw {
|
||||
t := strings.TrimRight(ln, " \t")
|
||||
if strings.HasSuffix(t, "\\") {
|
||||
buf.WriteString(strings.TrimSuffix(t, "\\"))
|
||||
buf.WriteString(" ")
|
||||
carrying = true
|
||||
continue
|
||||
}
|
||||
buf.WriteString(ln)
|
||||
out = append(out, buf.String())
|
||||
buf.Reset()
|
||||
carrying = false
|
||||
}
|
||||
if carrying || buf.Len() > 0 {
|
||||
out = append(out, buf.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestExtractCmdRefs_Unit(t *testing.T) {
|
||||
doc := "`lark-cli apps +create --name x --app-type html`\n" +
|
||||
"`+db-table-list`, `+db-table-get`\n" +
|
||||
"lark-cli apps +session-list --app-id x | jq '.y --post-pipe-flag'\n" +
|
||||
"lark-cli apps +foo --bar baz \\\n --qux 1\n" +
|
||||
"人名→`ou_` 用 `lark-cli contact +search-user --query <名字>`,群名→`oc_` 用 `lark-cli im +chat-search --query <群名>`\n" +
|
||||
"改库走 `+db-*`;发布走 `+release-*`\n" +
|
||||
"不存在 `apps +pull` / `apps +push` / `apps code +read` 这类 shortcut,不要臆造。\n" +
|
||||
"`+git-credential-list` 列出本地凭证,不需要 `--app-id`。\n"
|
||||
|
||||
refs := extractCmdRefs(doc)
|
||||
got := map[string][]string{}
|
||||
for _, r := range refs {
|
||||
got[r.cmd] = append(got[r.cmd], r.flags...)
|
||||
}
|
||||
|
||||
// Full invocation: command + both flags captured.
|
||||
if _, ok := got["+create"]; !ok {
|
||||
t.Fatalf("missing +create; got %+v", refs)
|
||||
}
|
||||
if !contains(got["+create"], "name") || !contains(got["+create"], "app-type") {
|
||||
t.Errorf("+create flags wrong: %v", got["+create"])
|
||||
}
|
||||
|
||||
// Comma-separated command list: no flags attach to either command.
|
||||
if _, ok := got["+db-table-list"]; !ok {
|
||||
t.Errorf("missing +db-table-list; got %+v", refs)
|
||||
}
|
||||
if len(got["+db-table-list"]) != 0 || len(got["+db-table-get"]) != 0 {
|
||||
t.Errorf("comma-separated commands must carry no flags: %v", got)
|
||||
}
|
||||
|
||||
// Pipe stops capture within a SINGLE span (no surrounding backticks), so the
|
||||
// pipe `|` is the only boundary that can stop flag capture here: --app-id
|
||||
// (before the pipe) attaches, but the post-pipe --post-pipe-flag must NOT.
|
||||
if !contains(got["+session-list"], "app-id") {
|
||||
t.Errorf("pre-pipe flag should attach to +session-list: %v", got["+session-list"])
|
||||
}
|
||||
if contains(got["+session-list"], "post-pipe-flag") {
|
||||
t.Errorf("pipe did not stop flag capture: %v", got["+session-list"])
|
||||
}
|
||||
|
||||
// Backslash continuation joins --qux onto +foo (same logical line).
|
||||
if !contains(got["+foo"], "bar") || !contains(got["+foo"], "qux") {
|
||||
t.Errorf("continuation join failed: %v", got["+foo"])
|
||||
}
|
||||
|
||||
// Cross-service commands must NOT be attributed to apps.
|
||||
if _, ok := got["+search-user"]; ok {
|
||||
t.Errorf("contact +search-user must not be extracted as an apps command: %+v", refs)
|
||||
}
|
||||
if _, ok := got["+chat-search"]; ok {
|
||||
t.Errorf("im +chat-search must not be extracted as an apps command: %+v", refs)
|
||||
}
|
||||
|
||||
// Wildcard family references must NOT be extracted as commands.
|
||||
if _, ok := got["+db-"]; ok {
|
||||
t.Errorf("`+db-*` wildcard must not be extracted as a command: %+v", refs)
|
||||
}
|
||||
if _, ok := got["+release-"]; ok {
|
||||
t.Errorf("`+release-*` wildcard must not be extracted as a command: %+v", refs)
|
||||
}
|
||||
|
||||
// Deliberate negative examples are no longer line-skipped: the apps-prefixed
|
||||
// `+pull` / `+push` ARE extracted here (the consistency gate later excludes
|
||||
// them via documentedNonexistentExamples). `apps code +read` is preceded by
|
||||
// the bare qualifier "code", so the cross-service filter still drops it.
|
||||
for _, tok := range []string{"+pull", "+push"} {
|
||||
if _, ok := got[tok]; !ok {
|
||||
t.Errorf("negative example %s should still be extracted (gate excludes it, not the extractor): %+v", tok, refs)
|
||||
}
|
||||
if !documentedNonexistentExamples[tok] {
|
||||
t.Errorf("%s must be in documentedNonexistentExamples allowlist", tok)
|
||||
}
|
||||
}
|
||||
if _, ok := got["+read"]; ok {
|
||||
t.Errorf("`apps code +read` is cross-service (preceded by `code`) and must not be extracted: %+v", refs)
|
||||
}
|
||||
|
||||
// A --flag discussed in prose, in a separate inline-code span from the
|
||||
// command, must NOT attach to the command (backtick-span boundary stops
|
||||
// capture).
|
||||
if contains(got["+git-credential-list"], "app-id") {
|
||||
t.Errorf("prose flag in a separate backtick span must not attach: %v", got["+git-credential-list"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillDocsCommandsConsistentWithShortcuts(t *testing.T) {
|
||||
// Source of truth: the registered shortcuts and their flags.
|
||||
validCmd := map[string]map[string]bool{}
|
||||
for _, s := range Shortcuts() {
|
||||
fl := map[string]bool{}
|
||||
for _, f := range s.Flags {
|
||||
fl[f.Name] = true
|
||||
}
|
||||
validCmd[s.Command] = fl
|
||||
}
|
||||
|
||||
docs := skillDocFiles(t)
|
||||
if len(docs) == 0 {
|
||||
t.Fatal("no lark-apps skill docs found; gate cannot run")
|
||||
}
|
||||
|
||||
for _, path := range docs {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
rel := filepath.Base(path)
|
||||
for _, ref := range extractCmdRefs(string(raw)) {
|
||||
flags, ok := validCmd[ref.cmd]
|
||||
if !ok {
|
||||
// A deliberate negative example (documented as NOT existing) is
|
||||
// expected to be absent from Shortcuts(); skip only those.
|
||||
if documentedNonexistentExamples[ref.cmd] {
|
||||
continue
|
||||
}
|
||||
t.Errorf("%s: references `apps %s` which is not a registered shortcut", rel, ref.cmd)
|
||||
continue
|
||||
}
|
||||
for _, fl := range ref.flags {
|
||||
if flags[fl] || frameworkGlobalFlags[fl] {
|
||||
continue
|
||||
}
|
||||
t.Errorf("%s: `apps %s --%s`: --%s is not a flag of %s (have: %s)",
|
||||
rel, ref.cmd, fl, fl, ref.cmd, sortedFlags(flags))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// skillDocFiles returns SKILL.md + references/*.md for lark-apps, relative to
|
||||
// this package dir (go test cwd = shortcuts/apps/).
|
||||
func skillDocFiles(t *testing.T) []string {
|
||||
t.Helper()
|
||||
base := filepath.Join("..", "..", "skills", "lark-apps")
|
||||
var out []string
|
||||
if _, err := os.Stat(filepath.Join(base, "SKILL.md")); err == nil {
|
||||
out = append(out, filepath.Join(base, "SKILL.md"))
|
||||
}
|
||||
refs, _ := filepath.Glob(filepath.Join(base, "references", "*.md"))
|
||||
out = append(out, refs...)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedFlags(m map[string]bool) string {
|
||||
names := make([]string, 0, len(m))
|
||||
for n := range m {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func contains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -20,13 +20,9 @@ var AppsUpdate = common.Shortcut{
|
||||
Command: "+update",
|
||||
Description: "Partially update a Miaoda app (only provided fields are sent)",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +update --app-id <app_id> --name "新名称"`,
|
||||
`Example: lark-cli apps +update --app-id <app_id> --description "..."`,
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "name", Desc: "new app display name"},
|
||||
@@ -52,9 +48,9 @@ var AppsUpdate = common.Shortcut{
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("PATCH", path, nil, buildAppsUpdateBody(rctx))
|
||||
data, err := rctx.CallAPI("PATCH", path, nil, buildAppsUpdateBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "updated: %s\n", common.GetString(data, "app", "app_id"))
|
||||
|
||||
@@ -8,46 +8,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func testRuntimeWithNameDesc(t *testing.T, name, desc string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "update"}
|
||||
cmd.Flags().String("name", name, "")
|
||||
cmd.Flags().String("description", desc, "")
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
func TestBuildAppsUpdateBody_FieldCombos(t *testing.T) {
|
||||
t.Run("both empty -> empty body", func(t *testing.T) {
|
||||
if body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, " ", "")); len(body) != 0 {
|
||||
t.Errorf("empty inputs should yield empty body, got %v", body)
|
||||
}
|
||||
})
|
||||
t.Run("name only", func(t *testing.T) {
|
||||
body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, "App", ""))
|
||||
if body["name"] != "App" || len(body) != 1 {
|
||||
t.Errorf("name-only body=%v", body)
|
||||
}
|
||||
})
|
||||
t.Run("description only", func(t *testing.T) {
|
||||
body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, "", "desc"))
|
||||
if body["description"] != "desc" || len(body) != 1 {
|
||||
t.Errorf("desc-only body=%v", body)
|
||||
}
|
||||
})
|
||||
t.Run("both set and trimmed", func(t *testing.T) {
|
||||
body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, " App ", " d "))
|
||||
if body["name"] != "App" || body["description"] != "d" {
|
||||
t.Errorf("both body=%v", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppsUpdate_PartialFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// commandRunner abstracts external process execution so apps +init's
|
||||
// orchestration can be unit-tested without a real git binary or network.
|
||||
// dir == "" runs in the current working directory; a non-empty dir runs the
|
||||
// command with that working directory (git -C semantics).
|
||||
type commandRunner interface {
|
||||
Run(ctx context.Context, dir, name string, args ...string) (stdout, stderr string, err error)
|
||||
}
|
||||
|
||||
// execCommandRunner is the production commandRunner backed by os/exec.
|
||||
type execCommandRunner struct{}
|
||||
|
||||
func (execCommandRunner) Run(ctx context.Context, dir, name string, args ...string) (string, string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
// credentialURLRe matches the userinfo segment of an http(s) URL (the
|
||||
// "user:token@" part) so it can be redacted before any output or logging. The
|
||||
// negated class excludes only "/" and whitespace (not "@"), so the match
|
||||
// greedily consumes up to the LAST "@" before the host/path — this ensures a
|
||||
// literal "@" inside the userinfo (e.g. "user:p@ss@host") is fully redacted.
|
||||
var credentialURLRe = regexp.MustCompile(`(?i)(https?://)[^/\s]+@`)
|
||||
|
||||
// redactURLCredentials replaces the userinfo segment of any http(s) URL in s
|
||||
// with "***". Safe to call on both a bare repo_url and free-form text such as
|
||||
// git stderr (which echoes the full remote URL on failure).
|
||||
func redactURLCredentials(s string) string {
|
||||
return credentialURLRe.ReplaceAllString(s, "${1}***@")
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactURLCredentials(t *testing.T) {
|
||||
cases := []struct{ name, in, want string }{
|
||||
{"http with userinfo", "http://x-token:PAT_abc@git.host/app_x.git", "http://***@git.host/app_x.git"},
|
||||
{"https with userinfo", "https://u:p@h/r.git", "https://***@h/r.git"},
|
||||
{"no userinfo unchanged", "http://git.host/app_x.git", "http://git.host/app_x.git"},
|
||||
{"embedded in stderr text", "fatal: unable to access 'http://u:t@h/r.git/': 401", "fatal: unable to access 'http://***@h/r.git/': 401"},
|
||||
{"empty", "", ""},
|
||||
{"non-url unchanged", "some error message", "some error message"},
|
||||
{"uppercase scheme", "HTTP://u:t@h/r.git", "HTTP://***@h/r.git"},
|
||||
{"multiple @ in userinfo", "https://user:p@ss@host/r.git", "https://***@host/r.git"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := redactURLCredentials(c.in); got != c.want {
|
||||
t.Errorf("redactURLCredentials(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeCommandRunner records calls and returns scripted results keyed by the
|
||||
// command + first arg (e.g. "git clone", "git checkout", "git status"), or
|
||||
// "credential-init" for the self-invoked `apps +git-credential-init` call.
|
||||
type fakeCallResult struct {
|
||||
stdout, stderr string
|
||||
err error
|
||||
}
|
||||
|
||||
type fakeCommandRunner struct {
|
||||
results map[string]fakeCallResult
|
||||
calls [][]string // each entry: [dir, name, args...]
|
||||
}
|
||||
|
||||
func (f *fakeCommandRunner) Run(ctx context.Context, dir, name string, args ...string) (string, string, error) {
|
||||
rec := append([]string{dir, name}, args...)
|
||||
f.calls = append(f.calls, rec)
|
||||
key := name
|
||||
if len(args) > 0 {
|
||||
key = name + " " + args[0]
|
||||
}
|
||||
if name != "git" && len(args) >= 2 && args[0] == "apps" {
|
||||
switch args[1] {
|
||||
case "+env-pull":
|
||||
key = "env-pull"
|
||||
default:
|
||||
key = "credential-init"
|
||||
}
|
||||
}
|
||||
if r, ok := f.results[key]; ok {
|
||||
return r.stdout, r.stderr, r.err
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
@@ -3,50 +3,8 @@
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// appsService 是 CLI 命令的 service 前缀(lark-cli apps ...)。
|
||||
const appsService = "apps"
|
||||
|
||||
// apiBasePath is the registered OAPI prefix for the Miaoda apps domain.
|
||||
const apiBasePath = "/open-apis/spark/v1"
|
||||
|
||||
// appIDListHint is the shared recovery hint for commands whose most likely
|
||||
// failure cause is a wrong/inaccessible --app-id. It points at +list to find
|
||||
// the correct Miaoda app id. The app_/cli_ format rule is taught in
|
||||
// lark-apps SKILL.md ("app_id 获取"); the hint stays lean and does not repeat it.
|
||||
const appIDListHint = "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`"
|
||||
|
||||
// withAppsHint attaches an actionable next-step hint to a failure returned by
|
||||
// CallAPI, preserving its original classification (typed subtype/code/log_id or
|
||||
// legacy detail). A hint already present on the error is kept (the upstream
|
||||
// wording wins); only an empty hint is filled in. Mirrors
|
||||
// drive.appendDriveExportRecoveryHint. err==nil passes through.
|
||||
func withAppsHint(err error, hint string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// p points at the embedded Problem, so the mutation is reflected in err.
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) == "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Legacy *output.ExitError fallback: fill the hint in place, preserving the
|
||||
// original class / exit code rather than downgrading the error.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) == "" {
|
||||
exitErr.Detail.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWithAppsHint(t *testing.T) {
|
||||
t.Run("nil error stays nil", func(t *testing.T) {
|
||||
if got := withAppsHint(nil, "do x"); got != nil {
|
||||
t.Fatalf("withAppsHint(nil) = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty hint gets filled, code/type preserved", func(t *testing.T) {
|
||||
in := &output.ExitError{Code: 1, Detail: &output.ErrDetail{Type: "api_error", Message: "boom"}}
|
||||
out := withAppsHint(in, "run +release-list")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(out, &exitErr) {
|
||||
t.Fatalf("returned error is not *output.ExitError: %T", out)
|
||||
}
|
||||
if exitErr.Detail.Hint != "run +release-list" {
|
||||
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "run +release-list")
|
||||
}
|
||||
if exitErr.Code != 1 || exitErr.Detail.Type != "api_error" || exitErr.Detail.Message != "boom" {
|
||||
t.Errorf("code/type/message mutated: code=%d type=%q msg=%q", exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing hint is preserved, not clobbered", func(t *testing.T) {
|
||||
in := output.ErrWithHint(1, "api_error", "boom", "original hint")
|
||||
out := withAppsHint(in, "new hint")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(out, &exitErr) {
|
||||
t.Fatalf("returned error is not *output.ExitError: %T", out)
|
||||
}
|
||||
if exitErr.Detail.Hint != "original hint" {
|
||||
t.Errorf("Hint = %q, want preserved %q", exitErr.Detail.Hint, "original hint")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank-whitespace hint is treated as empty and filled", func(t *testing.T) {
|
||||
in := output.ErrWithHint(1, "api_error", "boom", " ")
|
||||
out := withAppsHint(in, "filled hint")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(out, &exitErr) {
|
||||
t.Fatalf("returned error is not *output.ExitError: %T", out)
|
||||
}
|
||||
if exitErr.Detail.Hint != "filled hint" {
|
||||
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "filled hint")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrecognized error type returned unchanged, no panic", func(t *testing.T) {
|
||||
in := errors.New("plain")
|
||||
out := withAppsHint(in, "ignored")
|
||||
if out == nil || out.Error() != "plain" {
|
||||
t.Fatalf("withAppsHint(plain) = %v, want unchanged plain error", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAppTablesPath_ReusesExistingURL(t *testing.T) {
|
||||
if got := appTablesPath("app_x"); got != "/open-apis/spark/v1/apps/app_x/tables" {
|
||||
t.Fatalf("appTablesPath = %q (want existing /apps/{id}/tables, not /db/tables)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppTablePath_EncodesSegments(t *testing.T) {
|
||||
if got := appTablePath("app_x", "my table"); got != "/open-apis/spark/v1/apps/app_x/tables/my%20table" {
|
||||
t.Fatalf("appTablePath = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppSQLPath_ReusesExistingURL(t *testing.T) {
|
||||
if got := appSQLPath("app_x"); got != "/open-apis/spark/v1/apps/app_x/sql_commands" {
|
||||
t.Fatalf("appSQLPath = %q (want /apps/{id}/sql_commands)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDbEnvCreatePath_NewURL(t *testing.T) {
|
||||
// db-env-create 是本期新增接口,URL 走 /db_dev_init(与上面三条复用 URL 不同)。
|
||||
if got := appDbEnvCreatePath("app_x"); got != "/open-apis/spark/v1/apps/app_x/db_dev_init" {
|
||||
t.Fatalf("appDbEnvCreatePath = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAppID_BlankRejected(t *testing.T) {
|
||||
if _, err := requireAppID(" "); err == nil {
|
||||
t.Fatal("expected error for blank app-id")
|
||||
}
|
||||
got, err := requireAppID(" app_x ")
|
||||
if err != nil || got != "app_x" {
|
||||
t.Fatalf("requireAppID trimmed = %q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -1,552 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // Git credential list scans CLI config-dir state; it is not user file I/O.
|
||||
)
|
||||
|
||||
type gitCredentialAppStorage struct{}
|
||||
|
||||
func (gitCredentialAppStorage) Read(appID, key string) ([]byte, error) {
|
||||
return Read(appID, key)
|
||||
}
|
||||
|
||||
func (gitCredentialAppStorage) Write(appID, key string, data []byte) error {
|
||||
return Write(appID, key, data)
|
||||
}
|
||||
|
||||
func (gitCredentialAppStorage) Delete(appID, key string) error {
|
||||
return Delete(appID, key)
|
||||
}
|
||||
|
||||
func (gitCredentialAppStorage) ListAppIDs() ([]string, error) {
|
||||
root := filepath.Join(core.GetConfigDir(), storageRoot)
|
||||
entries, err := vfs.ReadDir(root)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("apps storage: read root: %w", err)
|
||||
}
|
||||
appIDs := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
appID, err := url.PathUnescape(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := checkSeg(appID, "appID"); err != nil {
|
||||
continue
|
||||
}
|
||||
appIDs = append(appIDs, appID)
|
||||
}
|
||||
return appIDs, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,138 +0,0 @@
|
||||
// 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, "'", "'\\''") + "'"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,475 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type Issuer interface {
|
||||
Issue(ctx context.Context, appID string, profile ProfileContext) (*IssuedCredential, error)
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
Store *Store
|
||||
Secrets *SecretStore
|
||||
GitConfig GitConfig
|
||||
Issuer Issuer
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer Issuer) *Manager {
|
||||
return &Manager{
|
||||
Store: store,
|
||||
Secrets: secrets,
|
||||
GitConfig: gitConfig,
|
||||
Issuer: issuer,
|
||||
Now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string) (*InitResult, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if err := validate.ResourceName(appID, "--app-id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile.UserOpenID == "" {
|
||||
return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`")
|
||||
}
|
||||
unlockApp, err := lockApp(appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err)
|
||||
}
|
||||
defer unlockApp()
|
||||
if m.Issuer == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "git credential issuer is not configured")
|
||||
}
|
||||
issued, err := m.Issuer.Issue(ctx, appID, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url, err := NormalizeGitHTTPURL(issued.GitHTTPURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := m.nowUnix()
|
||||
if err := validateIssuedCredential(appID, url, issued, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref := BuildPATRef(profile, appID)
|
||||
previous, err := m.currentAppRecord(appID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var previousPAT string
|
||||
if previous != nil {
|
||||
previousPAT, _ = m.Secrets.Get(previous.PATRef)
|
||||
}
|
||||
record := CredentialRecord{
|
||||
AppID: appID,
|
||||
GitHTTPURL: url,
|
||||
Profile: profile.Profile,
|
||||
ProfileAppID: profile.ProfileAppID,
|
||||
UserOpenID: profile.UserOpenID,
|
||||
Username: defaultUsername(issued.Username),
|
||||
PATRef: ref,
|
||||
Status: StatusPending,
|
||||
ExpiresAt: issued.ExpiresAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := m.Store.Upsert(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.Secrets.Set(ref, issued.PAT); err != nil {
|
||||
m.restoreAfterInitFailure(appID, previous, previousPAT)
|
||||
return nil, err
|
||||
}
|
||||
record.Status = StatusConfirmed
|
||||
if err := m.Store.Upsert(record); err != nil {
|
||||
if previous != nil && previous.PATRef == ref && previousPAT != "" {
|
||||
_ = m.Secrets.Set(ref, previousPAT)
|
||||
} else {
|
||||
_ = m.Secrets.Remove(ref)
|
||||
}
|
||||
m.restoreAfterInitFailure(appID, previous, previousPAT)
|
||||
return nil, err
|
||||
}
|
||||
if previous != nil && previous.PATRef != "" && previous.PATRef != ref {
|
||||
_ = m.Secrets.Remove(previous.PATRef)
|
||||
}
|
||||
result := &InitResult{AppID: appID, GitHTTPURL: url, Refreshed: previous != nil}
|
||||
if m.GitConfig != nil {
|
||||
if err := m.GitConfig.SetHelper(ctx, url, appID); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
} else if previous != nil && previous.GitHTTPURL != "" && previous.GitHTTPURL != url {
|
||||
if err := m.GitConfig.UnsetHelper(ctx, previous.GitHTTPURL); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Remove(ctx context.Context, profile ProfileContext, appID string) (*RemoveResult, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if err := validate.ResourceName(appID, "--app-id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unlockApp, err := lockApp(appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err)
|
||||
}
|
||||
defer unlockApp()
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &RemoveResult{AppID: appID, Records: records}
|
||||
for _, record := range records {
|
||||
if err := m.Secrets.Remove(record.PATRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.GitConfig != nil {
|
||||
if err := m.GitConfig.UnsetHelper(ctx, record.GitHTTPURL); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
}
|
||||
}
|
||||
if _, err := m.Store.DeleteByURL(record.GitHTTPURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Removed = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) List() (*ListResult, error) {
|
||||
records, err := m.Store.Records()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ListRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
out = append(out, m.listRecord(record))
|
||||
}
|
||||
return &ListResult{Records: out}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Get(ctx context.Context, input CredentialInput, current ProfileContext, out, errOut io.Writer) error {
|
||||
url, err := NormalizeCredentialInput(input)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
record, pat, ok, err := m.readConfirmed(url, current)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if m.usable(record, pat) {
|
||||
return writeGitCredential(out, record.Username, pat)
|
||||
}
|
||||
|
||||
unlock := lockURL(url)
|
||||
defer unlock()
|
||||
unlockApp, err := lockApp(record.AppID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: acquire lock for %s: %s\n", record.AppID, err)
|
||||
return nil
|
||||
}
|
||||
defer unlockApp()
|
||||
|
||||
record, pat, ok, err = m.readConfirmed(url, current)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if m.usable(record, pat) {
|
||||
return writeGitCredential(out, record.Username, pat)
|
||||
}
|
||||
if m.Issuer == nil {
|
||||
fmt.Fprintln(errOut, "Git credential refresh failed: issuer is not configured")
|
||||
return nil
|
||||
}
|
||||
issued, err := m.Issuer.Issue(ctx, record.AppID, current)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\nNext step: lark-cli apps +git-credential-init --app-id %s\n", err, record.AppID)
|
||||
return nil
|
||||
}
|
||||
issuedURL, urlErr := NormalizeGitHTTPURL(issued.GitHTTPURL)
|
||||
if urlErr != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", urlErr)
|
||||
return nil
|
||||
}
|
||||
if err := validateIssuedCredential(record.AppID, issuedURL, issued, m.nowUnix()); err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if issuedURL != url {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: issued repository URL %q does not match initialized URL %q\n", issuedURL, url)
|
||||
return nil
|
||||
}
|
||||
if issued.ExpiresAt < record.ExpiresAt {
|
||||
latest, latestPAT, found, readErr := m.readConfirmed(url, current)
|
||||
if readErr != nil {
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", readErr)
|
||||
return nil
|
||||
}
|
||||
if found && m.usable(latest, latestPAT) {
|
||||
return writeGitCredential(out, latest.Username, latestPAT)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
record.Username = defaultUsername(issued.Username)
|
||||
record.ExpiresAt = issued.ExpiresAt
|
||||
record.UpdatedAt = m.nowUnix()
|
||||
record.InvalidatedAt = 0
|
||||
record.Status = StatusConfirmed
|
||||
oldPAT := pat
|
||||
if err := m.Secrets.Set(record.PATRef, issued.PAT); err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if err := m.Store.Upsert(record); err != nil {
|
||||
_ = m.Secrets.Set(record.PATRef, oldPAT)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
return writeGitCredential(out, record.Username, issued.PAT)
|
||||
}
|
||||
|
||||
func (m *Manager) currentAppRecord(appID string) (*CredentialRecord, error) {
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
if err != nil || len(records) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &records[0], nil
|
||||
}
|
||||
|
||||
func (m *Manager) restoreAfterInitFailure(appID string, existing *CredentialRecord, existingPAT string) {
|
||||
if existing == nil {
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
if err == nil {
|
||||
for _, record := range records {
|
||||
_, _ = m.Store.DeleteByURL(record.GitHTTPURL)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = m.Store.Upsert(*existing)
|
||||
if existingPAT != "" {
|
||||
_ = m.Secrets.Set(existing.PATRef, existingPAT)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) listRecord(record CredentialRecord) ListRecord {
|
||||
now := m.nowUnix()
|
||||
status := ListStatusValid
|
||||
expired := record.ExpiresAt <= now
|
||||
switch {
|
||||
case record.Status != StatusConfirmed || record.GitHTTPURL == "" || record.PATRef == "":
|
||||
status = ListStatusIncomplete
|
||||
case record.InvalidatedAt > 0:
|
||||
status = ListStatusInvalidated
|
||||
case !m.hasSecret(record.PATRef):
|
||||
status = ListStatusMissingSecret
|
||||
case expired:
|
||||
status = ListStatusExpired
|
||||
}
|
||||
return ListRecord{
|
||||
AppID: record.AppID,
|
||||
GitHTTPURL: record.GitHTTPURL,
|
||||
Status: status,
|
||||
ExpiresAt: record.ExpiresAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
Profile: record.Profile,
|
||||
ProfileAppID: record.ProfileAppID,
|
||||
UserOpenID: record.UserOpenID,
|
||||
Expired: expired,
|
||||
InvalidatedAt: record.InvalidatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) hasSecret(ref string) bool {
|
||||
pat, err := m.Secrets.Get(ref)
|
||||
return err == nil && pat != ""
|
||||
}
|
||||
|
||||
func (m *Manager) StoreCredential(r io.Reader) error {
|
||||
_, err := io.Copy(io.Discard, r)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) Erase(r io.Reader) error {
|
||||
input, err := ParseCredentialInput(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url, err := NormalizeCredentialInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record, err := m.Store.FindByURL(url)
|
||||
if err != nil || record == nil {
|
||||
return err
|
||||
}
|
||||
unlockApp, err := lockApp(record.AppID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire Git credential lock for %s: %w", record.AppID, err)
|
||||
}
|
||||
defer unlockApp()
|
||||
record, err = m.Store.FindByURL(url)
|
||||
if err != nil || record == nil {
|
||||
return err
|
||||
}
|
||||
now := m.nowUnix()
|
||||
if record.LastEraseAt > 0 && now-record.LastEraseAt < int64(eraseCooldown.Seconds()) {
|
||||
return nil
|
||||
}
|
||||
record.InvalidatedAt = now
|
||||
record.LastEraseAt = now
|
||||
if err := m.Store.Upsert(*record); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Secrets.Remove(record.PATRef)
|
||||
}
|
||||
|
||||
func (m *Manager) readConfirmed(url string, current ProfileContext) (CredentialRecord, string, bool, error) {
|
||||
record, err := m.Store.FindByURL(url)
|
||||
if err != nil || record == nil {
|
||||
return CredentialRecord{}, "", false, err
|
||||
}
|
||||
if record.ProfileAppID != current.ProfileAppID || record.UserOpenID != current.UserOpenID {
|
||||
return CredentialRecord{}, "", false, fmt.Errorf("current login does not match initialized credential; run `lark-cli apps +git-credential-init --app-id %s` with the current login or switch back to the original account", record.AppID)
|
||||
}
|
||||
pat, err := m.Secrets.Get(record.PATRef)
|
||||
if err != nil {
|
||||
pat = ""
|
||||
}
|
||||
return *record, pat, true, nil
|
||||
}
|
||||
|
||||
func (m *Manager) usable(record CredentialRecord, pat string) bool {
|
||||
if record.Status != StatusConfirmed || pat == "" || record.InvalidatedAt > 0 {
|
||||
return false
|
||||
}
|
||||
return record.ExpiresAt-m.nowUnix() > int64(refreshBeforeExpiry.Seconds())
|
||||
}
|
||||
|
||||
func (m *Manager) now() time.Time {
|
||||
if m != nil && m.Now != nil {
|
||||
return m.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (m *Manager) nowUnix() int64 {
|
||||
return m.now().Unix()
|
||||
}
|
||||
|
||||
func ParseCredentialInput(r io.Reader) (CredentialInput, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
var input CredentialInput
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "protocol":
|
||||
input.Protocol = value
|
||||
case "host":
|
||||
input.Host = value
|
||||
case "path":
|
||||
input.Path = value
|
||||
case "url":
|
||||
u, err := NormalizeGitHTTPURL(value)
|
||||
if err == nil {
|
||||
parsed, _ := parseNormalizedForInput(u)
|
||||
input = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return input, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func parseNormalizedForInput(raw string) (CredentialInput, error) {
|
||||
parts := strings.SplitN(raw, "://", 2)
|
||||
if len(parts) != 2 {
|
||||
return CredentialInput{}, output.ErrValidation("invalid credential URL")
|
||||
}
|
||||
hostPath := parts[1]
|
||||
idx := strings.Index(hostPath, "/")
|
||||
if idx < 0 {
|
||||
return CredentialInput{Protocol: parts[0], Host: hostPath, Path: "/"}, nil
|
||||
}
|
||||
return CredentialInput{Protocol: parts[0], Host: hostPath[:idx], Path: hostPath[idx:]}, nil
|
||||
}
|
||||
|
||||
func writeGitCredential(w io.Writer, username, pat string) error {
|
||||
if username == "" || pat == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "username=%s\n", username); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "password=%s\n", pat); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(w)
|
||||
return err
|
||||
}
|
||||
|
||||
func defaultUsername(username string) string {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return "x-access-token"
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
func validateIssuedCredential(appID, normalizedURL string, issued *IssuedCredential, now int64) error {
|
||||
if issued == nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: empty credential")
|
||||
}
|
||||
if issued.AppID != "" && issued.AppID != appID {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
|
||||
}
|
||||
if normalizedURL == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL")
|
||||
}
|
||||
if strings.TrimSpace(issued.PAT) == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token")
|
||||
}
|
||||
if issued.ExpiresAt <= now {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response expiredTime must be in the future")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
type SecretStore struct {
|
||||
kc keychain.KeychainAccess
|
||||
}
|
||||
|
||||
func NewSecretStore(kc keychain.KeychainAccess) *SecretStore {
|
||||
return &SecretStore{kc: kc}
|
||||
}
|
||||
|
||||
func (s *SecretStore) Get(ref string) (string, error) {
|
||||
if s == nil || ref == "" {
|
||||
return "", nil
|
||||
}
|
||||
if s.kc == nil {
|
||||
return "", nil
|
||||
}
|
||||
return s.kc.Get(KeychainService, ref)
|
||||
}
|
||||
|
||||
func (s *SecretStore) Set(ref, pat string) error {
|
||||
if s == nil || s.kc == nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "local keychain is unavailable",
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-init",
|
||||
}}
|
||||
}
|
||||
if ref == "" {
|
||||
return &errs.InternalError{Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Message: "keychain PAT reference is empty",
|
||||
}}
|
||||
}
|
||||
return s.kc.Set(KeychainService, ref, pat)
|
||||
}
|
||||
|
||||
func (s *SecretStore) Remove(ref string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if ref == "" {
|
||||
return nil
|
||||
}
|
||||
if s.kc == nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "local keychain is unavailable",
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove",
|
||||
}}
|
||||
}
|
||||
if err := s.kc.Remove(KeychainService, ref); err != nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "remove local Git credential PAT from keychain failed: " + err.Error(),
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove",
|
||||
}, Cause: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user