mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
6402080328
* fix(apps): detect the no-database failure by code or message The recovery flow for "db command against an app that has no database" keyed on one business code (500002759). The server has since renumbered that case to 400002465, which silently disabled the flow: users now see the raw internal message about workspace / app-id mapping and lose the cloud-development recovery steps entirely. Nothing catches the regression. There is no compile error, the unit tests compare against the same constant they set, and the dry-run E2E does not exercise a real response — the failure only shows up against a server that has already renumbered. Detect on code OR message instead. Both known codes are kept, plus narrow lowercase markers of the server's internal wording. The two channels have opposite failure modes: a code is precise but gets renumbered, a message survives renumbering but breaks on rewording or localization. Requiring either to match means one channel changing degrades nothing, and only a simultaneous change of both regresses. Markers stay deliberately narrow. "no db branch" in particular must not also swallow env-pull's "invalid db branch" case, which needs its own hint; a comment records that widening them requires a test proving the neighbours still pass through. Classification and the cause chain are untouched: the helper still mutates the problem in place and returns the same error value. * test(apps): assert the full typed-error contract in no-database cases Review feedback: the new subtests checked only Message and Hint, so a change that reclassified the failure — or replaced the error value and dropped the cause chain — would still have passed. Each case now asserts Category, Subtype and Code are untouched by the rewrite, and that the helper returns the same error value. Inputs use a concrete subtype rather than Unknown, so a clobbered classification is actually observable. One new case wraps a cause and asserts errors.Is still finds it through the rewrite. Also covers the predicate's defensive nil guard, which withAppsHint cannot reach on its own (ProblemOf returns ok=false for untyped errors), closing the two uncovered lines the coverage report flagged. Both withAppsHint and isAppNoDatabaseError are now at 100%. * fix(errclass): classify the db-domain business codes Three codes reaching the Apps db commands were absent from the Spark table, so BuildAPIError fell through to the CategoryAPI + SubtypeUnknown catch-all and the envelope carried no usable classification. "App has no database yet" registers as Validation / FailedPrecondition: the app resolves fine and the request is well-formed, but a prerequisite the caller must create first is missing, so retrying unchanged can never succeed. This moves its exit code from 1 to 2 — "fix the state" rather than "the call failed" — and a test pins that so a future reclassification has to be deliberate. Two codes cover it because the server renumbered the case into the 4xx band; the legacy one stays for older servers. "Table does not exist" registers as API / NotFound, an ordinary missing-resource lookup with no exit-code change. SubtypeNotFound has no APIHint default, which matters here: the Apps layer fills its command-scoped hint only when the classifier left Hint empty, so a context-free default would displace the more actionable one. A test guards that too.
164 lines
7.3 KiB
Go
164 lines
7.3 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package apps
|
||
|
||
import (
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
)
|
||
|
||
// appsService 是 CLI 命令的 service 前缀(lark-cli apps ...)。
|
||
const appsService = "apps"
|
||
|
||
// apiBasePath is the registered OAPI prefix for the 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 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`"
|
||
|
||
// appNoDatabaseCode / appNoDatabaseLegacyCode are the Spark business codes seen
|
||
// when a db command runs against an app that has not initialized a database yet.
|
||
// The raw server message carries internal workspace terminology, so the CLI
|
||
// rewrites it into a user-facing explanation and attaches a recoverable
|
||
// cloud-development next step (see appNoDatabaseMessage / appNoDatabaseHint).
|
||
//
|
||
// Two codes, not one: the server renumbered this case from 500002759 to
|
||
// 400002465 when the domain moved its client-class errors into the 4xx band.
|
||
// Keying on a single literal made the recovery flow disappear silently on the
|
||
// day that shipped — no compile error, no failing unit test (they assert against
|
||
// the same constant), and the dry-run E2E never reaches a live server. Hence
|
||
// isAppNoDatabaseError matches code OR message; see that function.
|
||
const (
|
||
appNoDatabaseCode = 400002465 // current
|
||
appNoDatabaseLegacyCode = 500002759 // pre-4xx renumber; kept so older servers still match
|
||
)
|
||
|
||
// appNoDatabaseMessage is the user-facing explanation for appNoDatabaseCode.
|
||
// It deliberately drops internal workspace / db-branch terms.
|
||
const appNoDatabaseMessage = "this app does not have a database yet"
|
||
|
||
// appNoDatabaseHint guides adding a database via Miaoda cloud development. It
|
||
// only uses existing commands with stable placeholder args, so a harness can
|
||
// execute it without matching natural-language error text. Adding a database is
|
||
// a cloud write: a failed read alone does not authorize it — confirm with the
|
||
// user before starting a +chat.
|
||
const appNoDatabaseHint = "ask the user whether to add a database through Miaoda cloud development; if confirmed, run `lark-cli apps +session-list --app-id <app_id>` and reuse an active session, or run `lark-cli apps +session-create --app-id <app_id>`; send the database requirement with `lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message \"<database requirement>\"`, poll `lark-cli apps +session-get --app-id <app_id> --session-id <session_id>` until `latest_turn.status=completed`, then retry the original db command"
|
||
|
||
// withAppsHint attaches an actionable next-step hint to a typed failure,
|
||
// preserving its original classification (subtype/code/log_id). 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 and
|
||
// untyped errors pass through unchanged.
|
||
//
|
||
// Special-case the "app has no database yet" failure (see isAppNoDatabaseError):
|
||
// rewrite the message to a user-facing explanation and force the
|
||
// cloud-development recovery hint, since the raw upstream message uses internal
|
||
// terms and any generic hint would be less actionable. That failure is only
|
||
// produced by db endpoints, so the override is safe to check for every apps
|
||
// command that funnels through here.
|
||
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 isAppNoDatabaseError(p) {
|
||
p.Message = appNoDatabaseMessage
|
||
p.Hint = appNoDatabaseHint
|
||
return err
|
||
}
|
||
if strings.TrimSpace(p.Hint) == "" {
|
||
p.Hint = hint
|
||
}
|
||
return err
|
||
}
|
||
return err
|
||
}
|
||
|
||
// appNoDatabaseMessageMarkers are lowercase substrings of the raw server message
|
||
// for the no-database failure, used as a fallback when the business code is not
|
||
// one the CLI knows. They quote the server's internal vocabulary — "db branch",
|
||
// "workspace id ... app id" — which is exactly why the message gets rewritten for
|
||
// users.
|
||
//
|
||
// Deliberately narrow. A looser marker such as "workspace" alone would swallow
|
||
// neighbouring db failures that need their own hint; "no db branch" in particular
|
||
// must not also match env-pull's "invalid db branch" case
|
||
// (isEnvPullDevDBNotInitializedError). Widen only with a test proving the
|
||
// neighbours still pass through.
|
||
var appNoDatabaseMessageMarkers = []string{
|
||
"get workspace id failed by app id",
|
||
"no db branch",
|
||
}
|
||
|
||
// isAppNoDatabaseError reports whether a typed failure is "this app has no
|
||
// database yet", matching on business code OR raw server message.
|
||
//
|
||
// Why both: the code is the precise signal but not a stable one — the server has
|
||
// already renumbered this case once (500002759 → 400002465), and a code-only
|
||
// check fails open, silently dropping the recovery flow with nothing in CI to
|
||
// catch it. The message is the reverse trade: it survives renumbering but breaks
|
||
// on rewording or localization. Requiring either to match means one channel
|
||
// changing degrades nothing, and only a simultaneous change of both regresses.
|
||
func isAppNoDatabaseError(p *errs.Problem) bool {
|
||
if p == nil {
|
||
return false
|
||
}
|
||
if p.Code == appNoDatabaseCode || p.Code == appNoDatabaseLegacyCode {
|
||
return true
|
||
}
|
||
message := strings.ToLower(p.Message)
|
||
for _, marker := range appNoDatabaseMessageMarkers {
|
||
if strings.Contains(message, marker) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// validateRealAppID checks that --app-id is a real app ID (app_ prefix).
|
||
// meta_token values are rejected with a hint to resolve via +get first.
|
||
func validateRealAppID(appID string) error {
|
||
if !strings.HasPrefix(appID, "app_") {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
`--app-id must be an app_id starting with "app_".`,
|
||
).WithParam("--app-id").WithHint(
|
||
`If you have a meta_token or a /page/<token>/ link, first resolve it:
|
||
lark-cli apps +get --app-id <meta_token> -q '.data.app.app_id'
|
||
Then retry this command with the returned app_id.`,
|
||
)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
|
||
// --output path. The authoritative guard is the local FileIO layer
|
||
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and
|
||
// symlinks), so traversal is already blocked at write time; this gives an
|
||
// earlier, clearer validation error and pins the contract in the command layer.
|
||
// Empty (use server-derived default) passes through. Absolute paths and any
|
||
// ".." path component are rejected.
|
||
func rejectOutputTraversal(output string) error {
|
||
o := strings.TrimSpace(output)
|
||
if o == "" {
|
||
return nil
|
||
}
|
||
if filepath.IsAbs(o) {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
"--output must be a relative path within the current directory, got %q", o).WithParam("--output")
|
||
}
|
||
for _, seg := range strings.Split(filepath.Clean(o), string(filepath.Separator)) {
|
||
if seg == ".." {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
"--output must not contain .. path traversal, got %q", o).WithParam("--output")
|
||
}
|
||
}
|
||
return nil
|
||
}
|