Documentation
¶
Index ¶
Constants ¶
This section is empty.
Variables ¶
var AppsAccessScopeGet = common.Shortcut{ Service: appsService, Command: "+access-scope-get", Description: "Get 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, 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 appsValidationParamError("--app-id", "--app-id is required") } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID := strings.TrimSpace(rctx.Str("app-id")) return common.NewDryRunAPI(). GET(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))). Desc("Get app access scope") }, 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) 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`") } rctx.OutFormat(data, nil, func(w io.Writer) { fmt.Fprintf(w, "scope: %v\n", data["scope"]) }) return nil }, }
AppsAccessScopeGet reads the current access scope configuration of an app. 响应原样透传服务端契约(字符串 scope 枚举 All/Tenant/Range + 拆分的 users/departments/chats 数组)。
var AppsAccessScopeSet = common.Shortcut{ Service: appsService, Command: "+access-scope-set", Description: "Set 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, 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"}}, {Name: "targets", Desc: `targets JSON array: [{"type":"user|department|chat","id":"..."}, ...]`}, {Name: "apply-enabled", Type: "bool", Desc: "allow apply for access (scope=specific)"}, {Name: "approver", Desc: "approver open_id (when --apply-enabled; server allows exactly one)"}, {Name: "require-login", Type: "bool", Desc: "require login (scope=public)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("app-id")) == "" { return appsValidationParamError("--app-id", "--app-id is required") } return validateAccessScopeFlags(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID := strings.TrimSpace(rctx.Str("app-id")) dry := common.NewDryRunAPI(). PUT(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))). Desc("Set app access scope") body, bodyErr := buildAccessScopeBody(rctx) if bodyErr != nil { dry.Set("body_error", bodyErr.Error()) } else { dry.Body(body) } return dry }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { body, err := buildAccessScopeBody(rctx) if err != nil { return err } 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) 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>`") } rctx.OutFormat(data, nil, func(w io.Writer) { fmt.Fprintf(w, "access-scope set: %s\n", rctx.Str("scope")) }) return nil }, }
AppsAccessScopeSet sets the app's access scope (specific / public / tenant).
var AppsAnalyticsList = common.Shortcut{ Service: appsService, Command: "+analytics-list", Description: "List online app user and page-view analytics", Risk: "read", Tips: []string{ "Example: lark-cli apps +analytics-list --app-id <app_id> --analytics users --granularity week", "Tip: analytics timestamps use nanoseconds; use +metric-list for request/runtime metrics.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online analytics should be listed", Required: true}, {Name: appsEnvironmentFlag, Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"}, {Name: "analytics", Desc: "analytics family to list", Required: true, Enum: []string{"users", "page-view"}}, {Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, {Name: "page", Desc: "frontend page or route filter"}, {Name: "device-type", Desc: "device type filter", Enum: []string{"desktop", "mobile"}}, {Name: "granularity", Default: defaultAppsAnalyticsGranular, Desc: "analytics aggregation granularity", Enum: []string{"day", "week", "month"}}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } _, _, _, err := buildAnalyticsListBody(rctx) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { body, _, _, _ := buildAnalyticsListBody(rctx) return common.NewDryRunAPI(). POST(analyticsListPath(rctx.Str("app-id"))). Desc("List online app analytics"). Body(body) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, _ := requireAppID(rctx.Str("app-id")) body, types, labels, err := buildAnalyticsListBody(rctx) if err != nil { return err } data, err := rctx.CallAPITyped("POST", analyticsListPath(appID), nil, body) if err != nil { return withAppsHint(err, appIDListHint) } out := observabilitySeriesOutput{ Items: normalizeAnalyticsSeries(data, types, labels), HasMore: false, } rctx.OutFormat(out, nil, func(w io.Writer) { rows := observabilitySeriesRows(out.Items) sortObservabilityRowsDesc(rows, "timestamp_ns") rows = filterObservabilityRowsWithTime(rows, "timestamp_ns") appsPrintSchemaTable(w, rows, analyticsSeriesSchema(labels)) }) return nil }, }
AppsAnalyticsList lists online app product analytics.
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 appsValidationParamError("--app-id", "--app-id is required") } if strings.TrimSpace(rctx.Str("session-id")) == "" { return appsValidationParamError("--session-id", "--session-id is required") } if strings.TrimSpace(rctx.Str("message")) == "" { return appsValidationParamError("--message", "--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 }, }
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 AppsCreate = common.Shortcut{ Service: appsService, Command: "+create", Description: "Create a new 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, 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: "description", Desc: "app description"}, {Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("name")) == "" { return appsValidationParamError("--name", "--name is required") } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). POST(apiBasePath + "/apps"). Desc("Create an app"). Body(buildAppsCreateBody(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { data, err := rctx.CallAPITyped("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx)) if err != nil { return withAppsHint(err, createHint) } rctx.OutFormat(data, nil, func(w io.Writer) { fmt.Fprintf(w, "created: %s\n", common.GetString(data, "app", "app_id")) }) return nil }, }
AppsCreate creates a new app.
var AppsDBAuditDisable = common.Shortcut{ Service: appsService, Command: "+db-audit-disable", Description: "Disable row-change audit logging for a table", Risk: "write", Tips: []string{ "Example: lark-cli apps +db-audit-disable --app-id <app_id> --table orders", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "table", Desc: "table to disable audit for", Required: true}, }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } return rejectLegacyEnvFlag(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). POST(appAuditSetPath(appID)). Desc("Disable table audit"). Params(map[string]interface{}{"env": dbEnv(rctx)}). Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } table := strings.TrimSpace(rctx.Str("table")) data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, map[string]interface{}{"table": table, "enabled": false}) if err != nil { return withAppsHint(err, dbAuditSetHint) } st := auditSetStatus(data, table) out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": false} rctx.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintf(w, "✓ Audit disabled for table '%s'\n", common.GetString(out, "table")) }) return nil }, }
AppsDBAuditDisable 关闭某张表的行级审计。
POST /apps/{app_id}/db/audit_set,body {table, enabled:false}。
var AppsDBAuditEnable = common.Shortcut{ Service: appsService, Command: "+db-audit-enable", Description: "Enable row-change audit logging for a table", Risk: "write", Tips: []string{ "Example: lark-cli apps +db-audit-enable --app-id <app_id> --table orders --retention 30d", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "table", Desc: "table to enable audit for", Required: true}, {Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"}, }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } return rejectLegacyEnvFlag(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). POST(appAuditSetPath(appID)). Desc("Enable table audit"). Params(map[string]interface{}{"env": dbEnv(rctx)}). Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } table := strings.TrimSpace(rctx.Str("table")) retention := rctx.Str("retention") stop := rctx.StartSpinner("Enabling audit logging for " + table) defer stop() data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, map[string]interface{}{"table": table, "enabled": true, "retention": retention}) stop() if err != nil { return withAppsHint(err, dbAuditSetHint) } st := auditSetStatus(data, table) ret := common.GetString(st, "retention") if ret == "" { ret = retention } out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": true, "retention": ret} rctx.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintf(w, "✓ Audit enabled for table '%s' (retention: %s)\n", common.GetString(out, "table"), ret) }) return nil }, }
AppsDBAuditEnable 为某张表开启行级审计(变更追溯)。
POST /apps/{app_id}/db/audit_set,body {table, enabled:true, retention}。--retention 默认 7d。
var AppsDBAuditList = common.Shortcut{ Service: appsService, Command: "+db-audit-list", Description: "List row-change audit events for one or more tables (cursor pagination)", Risk: "read", Tips: []string{ "Example: lark-cli apps +db-audit-list --app-id <app_id> --table orders", "Multiple tables: repeat --table; filter time with --since 7d / --until 2026-04-15.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true}, {Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"}, {Name: "until", Desc: "filter: event at or before; same formats as --since"}, {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, {Name: "page-token", Desc: "pagination cursor from previous response"}, }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } if err := rejectLegacyEnvFlag(rctx); err != nil { return err } if len(auditListTables(rctx)) == 0 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required (at least one table)").WithParam("--table") } return normalizeTimeFlags(rctx, "since", "until") }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). GET(appAuditListPath(appID)). Desc("List Miaoda app table audit events"). Params(buildAuditListParams(rctx, auditListTables(rctx))) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } requested := auditListTables(rctx) env := dbEnv(rctx) queryTables := requested var skipped []auditSkippedEntry if len(requested) > 1 { queryTables, skipped, err = filterAuditTables(rctx, appID, env, requested) if err != nil { return withAppsHint(err, dbChangelogHint) } if len(queryTables) == 0 { out := map[string]interface{}{"items": []auditLogItem{}, "has_more": false, "skipped": skipped} rctx.OutFormat(out, nil, func(w io.Writer) { io.WriteString(w, "No audit events found.\n") writeAuditSkipped(w, skipped, len(requested)) }) return nil } } data, err := rctx.CallAPITyped("GET", appAuditListPath(appID), buildAuditListParams(rctx, queryTables), nil) if err != nil { return withAppsHint(err, dbChangelogHint) } items := projectAuditLogItems(data["items"]) data["items"] = items if len(skipped) > 0 { data["skipped"] = skipped } else { delete(data, "skipped") } multi := len(requested) > 1 rctx.OutFormat(data, nil, func(w io.Writer) { renderAuditListPretty(w, items, skipped, len(requested), multi) }) return nil }, }
AppsDBAuditList 列出数据表的行级审计事件(INSERT/UPDATE/DELETE 的变更追溯)。
GET /apps/{app_id}/db/audit_list(cursor 分页)。--table 可重复传多张表;--since/--until 多格式时间。 operator 透传 {id,name}(json 还原对象、pretty 取 name);before/after 是条件出现的 JSON (INSERT 无 before、DELETE 无 after),json 还原成对象。
多表查询时,CLI 先用 schema(表是否存在)+ status(审计是否开启)在本地过滤,把不存在 / 未开启审计的表剔除后再查 audit_list,被剔除的表及原因放进 skipped(服务端不再返该字段)。
var AppsDBAuditStatus = common.Shortcut{ Service: appsService, Command: "+db-audit-status", Description: "Show table audit (row-change tracking) status", Risk: "read", Tips: []string{ "Example: lark-cli apps +db-audit-status --app-id <app_id>", "Check one table: --table orders", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "table", Desc: "show status for a single table (default: all configured tables)"}, }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } return rejectLegacyEnvFlag(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). GET(appAuditStatusPath(appID)). Desc("Get table audit status"). Params(buildAuditStatusParams(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), buildAuditStatusParams(rctx), nil) if err != nil { return withAppsHint(err, dbChangelogHint) } table := strings.TrimSpace(rctx.Str("table")) items := projectAuditStatusItems(data["items"]) if table != "" && len(items) == 0 { items = []map[string]interface{}{{"table": table, "enabled": false}} } // json:单表返对象、多表返数组。 var out interface{} if table != "" && len(items) == 1 { out = items[0] } else { out = map[string]interface{}{"items": items} } rctx.OutFormat(out, nil, func(w io.Writer) { renderAuditStatusPretty(w, items, table) }) return nil }, }
AppsDBAuditStatus 查看数据表的审计开关状态(哪些表开了行级审计、保留期)。
GET /apps/{app_id}/db/audit_status。--table 指定单表(无记录时占位 enabled=false); 不指定返回所有已配置表。json 单表返对象、多表返数组;pretty 单表 key/value、多表表格。
var AppsDBChangelogList = common.Shortcut{ Service: appsService, Command: "+db-changelog-list", Description: "List a Miaoda app database's DDL change history (cursor pagination)", Risk: "read", Tips: []string{ "Example: lark-cli apps +db-changelog-list --app-id <app_id>", "Pin a single change with --change-id; filter time with --since 7d / --until 2026-04-15.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "table", Desc: "filter by target table"}, {Name: "change-id", Desc: "look up a single change by id (returns that one record only)"}, {Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"}, {Name: "until", Desc: "filter: changed at or before; same formats as --since"}, {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, {Name: "page-token", Desc: "pagination cursor from previous response"}, }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } if err := rejectLegacyEnvFlag(rctx); err != nil { return err } return normalizeTimeFlags(rctx, "since", "until") }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). GET(appChangelogListPath(appID)). Desc("List Miaoda app DDL changelog"). Params(buildChangelogParams(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } data, err := rctx.CallAPITyped("GET", appChangelogListPath(appID), buildChangelogParams(rctx), nil) if err != nil { return withAppsHint(err, dbChangelogHint) } items := projectChangelogItems(data["items"]) data["items"] = items changeID := strings.TrimSpace(rctx.Str("change-id")) rctx.OutFormat(data, nil, func(w io.Writer) { renderChangelogPretty(w, items, changeID) }) return nil }, }
AppsDBChangelogList 列出应用数据库的 DDL 变更记录(建表/改表/索引等结构变更追溯)。
GET /apps/{app_id}/db/changelog_list(cursor 分页)。过滤:--table、--since/--until(多格式时间)。 --change-id 精确查单条(命中返单条、否则空)。operator 后端以 JSON 字符串透传 {id,name}, json 还原成对象、pretty 只展示 name。
var AppsDBDataExport = common.Shortcut{ Service: appsService, Command: "+db-data-export", Description: "Export rows from a Miaoda app table to a local file (csv/json/sql)", Risk: "read", Tips: []string{ "Example: lark-cli apps +db-data-export --app-id <app_id> --table orders --output ./orders.csv", "Format follows the --output extension: .csv / .json / .sql (default csv).", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "table", Desc: "source table", Required: true}, {Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"}, {Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"}, }, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } if err := rejectLegacyEnvFlag(rctx); err != nil { return err } if strings.TrimSpace(rctx.Str("table")) == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required").WithParam("--table") } if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit") } if err := rejectOutputTraversal(rctx.Str("output")); err != nil { return err } if _, _, err := exportFormatAndOutput(rctx); err != nil { return err } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) format, _, _ := exportFormatAndOutput(rctx) return common.NewDryRunAPI(). GET(appDataExportPath(appID)). Desc("Export Miaoda app table data (raw bytes)"). Params(map[string]interface{}{ "env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")), "format": format, "limit": rctx.Int("limit"), }) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } table := strings.TrimSpace(rctx.Str("table")) format, out, err := exportFormatAndOutput(rctx) if err != nil { return err } total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table) resp, err := rctx.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: appDataExportPath(appID), QueryParams: larkcore.QueryParams{ "env": []string{dbEnv(rctx)}, "table": []string{table}, "format": []string{format}, "limit": []string{strconv.Itoa(rctx.Int("limit"))}, }, }) if err != nil { return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint) } if b := bytes.TrimSpace(resp.RawBody); len(b) > 0 && b[0] == '{' { if _, cerr := rctx.ClassifyAPIResponse(resp); cerr != nil { return withAppsHint(cerr, dbDataExportHint) } } if resp.StatusCode >= 400 { return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkServer, "export failed: HTTP %d", resp.StatusCode).WithRetryable(), dbDataExportHint) } body := resp.RawBody if len(body) > dbDataExportMaxBytes { return errs.NewValidationError(errs.SubtypeInvalidArgument, "export exceeds 1 MB limit (%d bytes); filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets", len(body)) } saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{ ContentType: resp.Header.Get("Content-Type"), ContentLength: int64(len(body)), }, bytes.NewReader(body)) if err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output") } rows := 0 if totalErr == nil { rows = total if lim := rctx.Int("limit"); rows > lim { rows = lim } } else { rows = countDataRows(body, format) } resolved, perr := rctx.FileIO().ResolvePath(out) if perr != nil || resolved == "" { resolved = out } result := map[string]interface{}{ "table": table, "output": resolved, "format": format, "rows": rows, "size_bytes": saved.Size(), } rctx.OutFormat(result, nil, func(w io.Writer) { fmt.Fprintf(w, "✓ Exported %s → %s (%d rows)\n", table, resolved, rows) }) return nil }, }
AppsDBDataExport 把应用数据表导出到本地文件(csv/json/sql)。
GET /apps/{app_id}/db/data_export,返回原始字节(非 JSON 信封)。 行数不随导出文件返回:CLI 原子编排——先查 GetAppTableRecordList 的 total,再导出文件。 数据格式由 --output 扩展名推断(默认 csv,缺省输出 <table>.csv);上限 5000 行 / 1 MB。
var AppsDBDataImport = common.Shortcut{ Service: appsService, Command: "+db-data-import", Description: "Import rows from a local csv/json file into a Miaoda app table", Risk: "high-risk-write", Tips: []string{ "Example: lark-cli apps +db-data-import --app-id <app_id> --file ./orders.csv --yes", "Table defaults to the file name; override with --table.", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true}, {Name: "table", Desc: "target table (default: file name without extension)"}, }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } if err := rejectLegacyEnvFlag(rctx); err != nil { return err } if strings.TrimSpace(rctx.Str("file")) == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file") } if _, err := resolveDataFormat(filepath.Ext(rctx.Str("file")), false); err != nil { return err } if st, serr := rctx.FileIO().Stat(strings.TrimSpace(rctx.Str("file"))); serr == nil && st.Size() > dbDataImportMaxBytes { return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", st.Size()).WithParam("--file") } if importTableName(rctx) == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot infer target table from file name; specify --table").WithParam("--table") } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) fileName := filepath.Base(strings.TrimSpace(rctx.Str("file"))) return common.NewDryRunAPI(). POST(appDataImportPath(appID)). Desc("Import data file into Miaoda app table (multipart upload)"). Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}). Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } file := strings.TrimSpace(rctx.Str("file")) content, err := cmdutil.ReadInputFile(rctx.FileIO(), file) if err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file") } if len(content) > dbDataImportMaxBytes { return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", len(content)).WithParam("--file") } fileName := filepath.Base(file) table := importTableName(rctx) fd := larkcore.NewFormdata() fd.AddField("file_name", fileName) fd.AddFile("file", bytes.NewReader(content)) resp, err := rctx.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodPost, ApiPath: appDataImportPath(appID), QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}}, Body: fd, }, larkcore.WithFileUpload()) if err != nil { return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "import request failed").WithCause(err).WithRetryable(), dbDataImportHint) } data, err := rctx.ClassifyAPIResponse(resp) if err != nil { return withAppsHint(err, dbDataImportHint) } outTable := common.GetString(data, "table") if outTable == "" { outTable = table } rows := int64(0) if f, ok := numericAsFloat(data["rows"]); ok { rows = int64(f) } out := map[string]interface{}{"file": file, "table": outTable, "rows": rows} rctx.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintf(w, "✓ Imported %s → table '%s' (%d rows)\n", file, outTable, rows) }) return nil }, }
AppsDBDataImport 把本地 csv/json 文件直传到应用数据表(high-risk-write)。
POST /apps/{app_id}/db/data_import,multipart 表单:file_name + 可选 table + 文件本体(与 +file-upload / UploadFileForOpenAPI 一致)。文件的格式解析与转换在服务端 integration 层完成 (按 file_name 扩展名推断 csv/json),CLI 不再本地解析。表名缺省取文件名(去扩展名)。上限 1 MB。
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 --environment dev --sync-data --app-id <app_id> --yes", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: append([]common.Flag{ {Name: "app-id", Desc: "app id", Required: true}, {Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"}, }, dbEnvFlags("dev", []string{"dev"}, "environment to create (only dev supported for now)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } return rejectLegacyEnvFlag(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). POST(appDbEnvCreatePath(appID)). Desc("Create 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 }, }
AppsDBEnvCreate creates a DB environment for an app(拆分单库为 dev/online 多环境)。
调 POST /apps/{app_id}/db_dev_init。--environment 指定要创建的环境,由调用方传入,目前只支持 dev。 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
var AppsDBEnvDiff = common.Shortcut{ Service: appsService, Command: "+db-env-diff", Description: "Preview pending dev→online schema changes (no apply)", Risk: "read", Tips: []string{ "Example: lark-cli apps +db-env-diff --app-id <app_id>", "Apply the previewed changes with +db-env-migrate --yes.", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { _, err := requireAppID(rctx.Str("app-id")) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Preview dev→online migration").Body(map[string]interface{}{"dry_run": true}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } stop := rctx.StartSpinner("Previewing migration diff (dev → online)") defer stop() data, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}) stop() if err != nil { return withAppsHint(err, dbEnvMigrateHint) } from, to := common.GetString(data, "from"), common.GetString(data, "to") changes := projectMigrationChanges(data["changes"]) out := map[string]interface{}{"from": from, "to": to, "changes": changes} rctx.OutFormat(out, nil, func(w io.Writer) { renderMigrationDiff(w, from, to, changes) }) return nil }, }
AppsDBEnvDiff 预览 dev→online 待发布的结构变更(不落地)。
POST /apps/{app_id}/db/env_migrate,body {dry_run:true},同步返 {from,to,changes[]}。 与 +db-env-migrate 同端点、dry_run 区分;预览也需 spark:app:write scope。
var AppsDBEnvMigrate = common.Shortcut{ Service: appsService, Command: "+db-env-migrate", Description: "Publish pending dev→online schema changes (irreversible)", Risk: "high-risk-write", Tips: []string{ "Example: lark-cli apps +db-env-migrate --app-id <app_id> --yes", "Preview first with +db-env-diff.", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { _, err := requireAppID(rctx.Str("app-id")) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Apply dev→online migration").Body(map[string]interface{}{"dry_run": false}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } stop := rctx.StartSpinner("Applying migration (dev → online)") defer stop() submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false}) if err != nil { return withAppsHint(err, dbEnvMigrateHint) } from, to := common.GetString(submit, "from"), common.GetString(submit, "to") taskID := common.GetString(submit, "task_id") applied := intFromAny(submit["changes_applied"]) if applied == 0 { applied = len(projectMigrationChanges(submit["changes"])) } if taskID != "" { final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute, func() (map[string]interface{}, error) { return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil) }, func(d map[string]interface{}) (bool, error) { switch strings.ToLower(common.GetString(d, "status")) { case "success", "applied", "migrated": return true, nil case "failed": return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", migrateFailMsg(d, taskID)), dbEnvMigrateHint) } return false, nil }) if perr != nil { return perr } if n := intFromAny(final["changes_applied"]); n > 0 { applied = n } } stop() out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied} rctx.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintf(w, "✓ Migrated %s → %s (%d changes)\n", from, to, applied) }) return nil }, }
AppsDBEnvMigrate 把 dev 的待发布结构变更发布到 online(异步,CLI 轮询至完成)。
POST /apps/{app_id}/db/env_migrate,body {dry_run:false} → task_id,轮询 env_migrate_status 至 success;后端 status:applied,CLI 对外统一呈现 migrated。high-risk-write。
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> --environment dev --file ./migration.sql --yes`, "Tip: single SELECT returns data as a row array — filter with --jq, e.g. -q '.data[].id'", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: append([]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"}, }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } if err := rejectLegacyEnvFlag(rctx); err != nil { return err } sql := strings.TrimSpace(rctx.Str("sql")) file := strings.TrimSpace(rctx.Str("file")) if sql != "" && file != "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sql and --file are mutually exclusive") } if file != "" { data, err := cmdutil.ReadInputFile(rctx.FileIO(), file) if err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err) } sql = strings.TrimSpace(string(data)) } if sql == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "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 `--environment dev`") } stmts := parseSQLResult(common.GetString(raw, "result")) data := shapeSQLData(stmts) 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 }, }
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 errs.APIError(CategoryAPI → exit 1),避免 agent 误判 ok:true 假成功。诊断信息 (第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地)写进 message+hint 文案(errs.* 信封扁平、无 detail 容器):失败在用户显式 BEGIN…COMMIT 事务内 → 整批回滚、前序未落库;否则前序语句已逐条 commit、未回滚。rolled_back 语义由 inferRolledBack 按 BEGIN/COMMIT 计数推断。
JSON(成功路径)按 SQL 类型归一化 `data`(不透传后端 result 字符串):
- 单 SELECT → data 是行数组 `[{...}]`(空 → `[]`)
- 单 DML → data = `{command, rows_affected}`
- 单 DDL → data = `{command}`
- 多语句 → data = `[{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]`
字段裁剪用框架原生 --jq/-q。
Risk: high-risk-write —— SQL 可含 DML/DDL,框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。
SQL 来源二选一:--sql(内联文本,或 - 读 stdin)/ --file(.sql 文件路径,受 CLI 相对路径约束)。 --file 在 Validate 阶段读出内容、归一化到 --sql,下游统一从 rctx.Str("sql") 取。
var AppsDBQuotaGet = common.Shortcut{ Service: appsService, Command: "+db-quota-get", Description: "Get an app's database storage usage", Risk: "read", Tips: []string{ "Example: lark-cli apps +db-quota-get --app-id <app_id>", "Example: lark-cli apps +db-quota-get --app-id <app_id> --environment dev", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } return rejectLegacyEnvFlag(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). GET(appDbQuotaPath(appID)). Desc("Get Miaoda app database storage usage"). Params(map[string]interface{}{"env": dbEnv(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", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil) if err != nil { return withAppsHint(err, appIDListHint) } out := projectDbQuota(data) rctx.OutFormat(out, nil, func(w io.Writer) { renderDbQuotaPretty(w, out) }) return nil }, }
AppsDBQuotaGet reports an app's database storage usage and object counts.
GET /apps/{app_id}/db/quota。storage_quota_bytes / usage_percent 在配额未对接(=0)时 不输出(与 +file-quota-get 一致);tables / views 始终输出。
var AppsDBRecoveryApply = common.Shortcut{ Service: appsService, Command: "+db-recovery-apply", Description: "Restore the database to a point in time (overwrites current data, irreversible)", Risk: "high-risk-write", Tips: []string{ "Example: lark-cli apps +db-recovery-apply --app-id <app_id> --target 2026-04-15T10:00:00Z --yes", "Preview first with +db-recovery-diff.", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } return normalizeTimeFlags(rctx, "target") }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery"). Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } target := rctx.Str("target") stop := rctx.StartSpinner("Restoring database (target: " + target + ")") defer stop() submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false}) if err != nil { return withAppsHint(err, dbRecoveryHint) } if strings.ToLower(common.GetString(submit, "status")) == "no_changes" { stop() out := map[string]interface{}{"status": "no_changes", "target": target} rctx.OutFormat(out, nil, func(w io.Writer) { io.WriteString(w, "No changes — database is already at this state.\n") }) return nil } final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute, func() (map[string]interface{}, error) { return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil) }, func(d map[string]interface{}) (bool, error) { switch strings.ToLower(common.GetString(d, "status")) { case "success", "restored", "ready": return true, nil case "failed": msg := common.GetString(d, "error_message") if msg == "" { msg = fmt.Sprintf("recovery to %s failed", target) } return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint) } return false, nil }) if perr != nil { return perr } stop() out := map[string]interface{}{"status": "restored", "target": target} if n := intFromAny(final["restore_time_sec"]); n > 0 { out["restore_time_sec"] = n } rctx.OutFormat(out, nil, func(w io.Writer) { if n, ok := out["restore_time_sec"].(int); ok { fmt.Fprintf(w, "✓ Database restored to %s (%ds elapsed)\n", target, n) } else { fmt.Fprintf(w, "✓ Database restored to %s\n", target) } }) return nil }, }
AppsDBRecoveryApply 把数据库恢复到某个时间点(覆盖当前数据,异步,CLI 轮询至完成)。
POST /apps/{app_id}/db/env_recovery,body {target, dry_run:false};目标=当前态时短路 no_changes, 否则轮询 env_recovery_apply_status 至 success。high-risk-write。
var AppsDBRecoveryDiff = common.Shortcut{ Service: appsService, Command: "+db-recovery-diff", Description: "Preview restoring the database to a point in time (PITR diff)", Risk: "read", Tips: []string{ "Example: lark-cli apps +db-recovery-diff --app-id <app_id> --target 2h", "Apply with +db-recovery-apply --target <same> --yes.", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } return normalizeTimeFlags(rctx, "target") }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery"). Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } target := rctx.Str("target") preview, err := runRecoveryPreview(rctx, appID, target) if err != nil { return err } out := recoveryDiffOutput(target, preview) rctx.OutFormat(out, nil, func(w io.Writer) { renderRecoveryDiff(w, target, out) }) return nil }, }
AppsDBRecoveryDiff 预览把数据库恢复到某个时间点会带来的变更(PITR diff,不落地)。
POST /apps/{app_id}/db/env_recovery,body {target, dry_run:true} → preview_request_id, 轮询 env_recovery_diff_status 至终态,返回受影响表与行数变化。预览也需 spark:app:write scope。
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: append([]common.Flag{ {Name: "app-id", Desc: "app id", Required: true}, {Name: "table", Desc: "table name", Required: true}, }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } if err := rejectLegacyEnvFlag(rctx); err != nil { return err } if strings.TrimSpace(rctx.Str("table")) == "" { return appsValidationParamError("--table", "--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 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) { io.WriteString(w, common.GetString(data, "ddl")) }) return nil }, }
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 AppsDBTableList = common.Shortcut{ Service: appsService, Command: "+db-table-list", Description: "List tables in an 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: append([]common.Flag{ {Name: "app-id", Desc: "app id", Required: true}, {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, {Name: "page-token", Desc: "pagination cursor from previous response"}, }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } return rejectLegacyEnvFlag(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). GET(appTablesPath(appID)). Desc("List 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) } items := projectTableListItems(data["items"]) data["items"] = items rctx.OutFormat(data, nil, func(w io.Writer) { renderTableListPretty(w, items) }) return nil }, }
AppsDBTableList lists tables in an 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 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 appsValidationParamError("--app-id", "--app-id is required") } _, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path"))) if err != nil { return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(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(envPullVarsPath(appID)). Desc("Pull app startup env vars into the local .env.local file"). Body(envPullVarsBody()). 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 appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err) } if err := checkEnvPullTarget(envFile); err != nil { return err } if err := rctx.EnsureScopes([]string{"spark:app:read"}); err != nil { return err } data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody()) if err != nil { return withAppsHint(err, envPullAPIErrorHint(err, appID)) } 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 }, }
AppsEnvPull pulls startup env vars for an app into the local .env.local file.
var AppsEnvVarDelete = common.Shortcut{ Service: appsService, Command: "+env-delete", Description: "Delete app environment variables", Risk: "high-risk-write", Tips: []string{ "Example: lark-cli apps +env-delete --app-id <app_id> --key FOO --yes", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, {Name: "key", Type: "string_array", Desc: "environment variable key; repeatable", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil { return err } _, err := requireEnvVarKeys(rctx.StrArray("key")) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) keys, _ := requireEnvVarKeys(rctx.StrArray("key")) return common.NewDryRunAPI(). POST(envVarDeletePath(appID)). Desc("Delete app environment variables"). Body(buildEnvVarDeleteBody(envVarEnv(rctx), keys)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } keys, err := requireEnvVarKeys(rctx.StrArray("key")) if err != nil { return err } env := envVarEnv(rctx) data, err := rctx.CallAPITyped("POST", envVarDeletePath(appID), nil, buildEnvVarDeleteBody(env, keys)) if err != nil { return withAppsHint(err, envVarMutationHint(err)) } deletedKeys := envVarStringSliceAny(data, "deleted_keys", "deletedKeys") if len(deletedKeys) == 0 { deletedKeys = keys } rctx.OutFormat(map[string]interface{}{ "env": env, "deleted_keys": deletedKeys, }, nil, nil) return nil }, }
AppsEnvVarDelete deletes one or more app environment variables.
var AppsEnvVarList = common.Shortcut{ Service: appsService, Command: "+env-list", Description: "List app environment variables", Risk: "read", Tips: []string{ "Example: lark-cli apps +env-list --app-id <app_id>", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, {Name: "include-values", Type: "bool", Desc: "include environment variable values"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil { return err } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). POST(envVarCollectionPath(appID)). Desc("List app environment variables"). Body(buildEnvVarListBody(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } includeValues := rctx.Bool("include-values") data, err := rctx.CallAPITyped("POST", envVarCollectionPath(appID), nil, buildEnvVarListBody(rctx)) if err != nil { return withAppsHint(err, appIDListHint) } out := normalizeEnvVarListOutput(data, includeValues) rctx.OutFormat(out, nil, func(w io.Writer) { appsPrintSchemaTable(w, out.Items, envVarListSchema(includeValues)) }) return nil }, }
AppsEnvVarList lists app environment variables without values by default.
var AppsEnvVarSet = common.Shortcut{ Service: appsService, Command: "+env-set", Description: "Set an app environment variable", Risk: "write", Tips: []string{ "Example: lark-cli apps +env-set --app-id <app_id> --key FOO --value bar", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, {Name: "key", Desc: "environment variable key", Required: true}, {Name: "value", Desc: "environment variable value", Required: true, Input: []string{common.File, common.Stdin}}, {Name: "yes", Type: "bool", Desc: "confirm setting variables in online"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil { return err } if _, err := requireEnvVarKey(rctx.Str("key")); err != nil { return err } if rctx.Str("value") == "" { return appsValidationParamError("--value", "--value is required") } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) key, _ := requireEnvVarKey(rctx.Str("key")) return common.NewDryRunAPI(). POST(envVarCreateOrUpdatePath(appID)). Desc("Set app environment variable"). Body(map[string]interface{}{ "key": key, "env": envVarEnv(rctx), "value": "<redacted>", }) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { env := envVarEnv(rctx) if env == "online" && !rctx.Bool("yes") { return errs.NewConfirmationRequiredError( errs.RiskWrite, "apps +env-set --environment online", "apps +env-set --environment online requires confirmation", ).WithHint("add --yes to confirm") } appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } key, err := requireEnvVarKey(rctx.Str("key")) if err != nil { return err } data, err := rctx.CallAPITyped("POST", envVarCreateOrUpdatePath(appID), nil, map[string]interface{}{ "key": key, "env": env, "value": rctx.Str("value"), }) if err != nil { return withAppsHint(err, envVarMutationHint(err)) } action := envVarStringAny(data, "action") if action == "" { action = "set" } rctx.OutFormat(map[string]interface{}{ "key": key, "env": env, "action": action, }, nil, nil) return nil }, }
AppsEnvVarSet sets one app environment variable. Values are never printed.
var AppsFileDelete = common.Shortcut{ Service: appsService, Command: "+file-delete", Description: "Delete one or more files by remote path (batch)", Risk: "high-risk-write", Tips: []string{ "Example: lark-cli apps +file-delete --app-id <app_id> --path /1858537546760216.png --yes", "Repeat --path for batch delete.", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "path", Type: "string_slice", Desc: "remote file path to delete (repeatable)", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } if len(cleanDeletePaths(rctx)) == 0 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--path is required (at least one remote path)").WithParam("--path") } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). POST(appFileBatchRemovePath(appID)). Desc("Batch delete Miaoda app files"). Body(map[string]interface{}{"paths": cleanDeletePaths(rctx)}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } paths := cleanDeletePaths(rctx) data, err := rctx.CallAPITyped("POST", appFileBatchRemovePath(appID), nil, map[string]interface{}{"paths": paths}) if err != nil { return err } results := projectDeleteResults(data["results"], paths) out := map[string]interface{}{"results": results} rctx.OutFormat(out, nil, func(w io.Writer) { renderFileDeletePretty(w, results) }) return nil }, }
AppsFileDelete batch-deletes files by remote path(high-risk-write,框架自动注入 --yes 确认)。
POST /apps/{app_id}/storage/file_batch_remove,body {paths:[...]}。网关把该路由注册为 POST (DELETE-with-body 不被网关支持,实测 DELETE→404 / POST→200)。后端 results[] 与请求 paths 顺序一一对应:成功项带 file,失败项带 error_code(CLI 据下标回填 path)。 部分失败整体仍 ok:true —— 失败项落在 data.results[].error,不翻成非 0 退出码(lark-cli 信封语义)。
var AppsFileDownload = common.Shortcut{ Service: appsService, Command: "+file-download", Description: "Download a file to a local path (via a signed URL)", Risk: "read", Tips: []string{ "Example: lark-cli apps +file-download --app-id <app_id> --path /1858537546760216.png --output ./logo.png", "Example (omit --output): lark-cli apps +file-download --app-id <app_id> --path /1858537546760216.png # saves to ./1858537546760216.png", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "path", Desc: "remote file path", Required: true}, {Name: "output", Desc: "local output path (default: remote file basename in cwd)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } if err := rejectOutputTraversal(rctx.Str("output")); err != nil { return err } _, err := requireFilePath(rctx.Str("path")) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) remotePath, _ := requireFilePath(rctx.Str("path")) return common.NewDryRunAPI(). POST(appFileSignPath(appID)). Desc("Sign a download URL, then GET it to --output"). Body(map[string]interface{}{"path": remotePath}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } remotePath, err := requireFilePath(rctx.Str("path")) if err != nil { return err } signData, err := rctx.CallAPITyped("POST", appFileSignPath(appID), nil, map[string]interface{}{"path": remotePath}) if err != nil { return err } signedURL := common.GetString(signData, "signed_url") if signedURL == "" { return errs.NewInternalError(errs.SubtypeInvalidResponse, "sign returned no signed_url") } out := strings.TrimSpace(rctx.Str("output")) if out == "" { out = path.Base(strings.TrimPrefix(remotePath, "/")) if out == "" || out == "." || out == "/" { out = "download" } } req, err := http.NewRequestWithContext(rctx.Ctx(), http.MethodGet, signedURL, nil) if err != nil { return errs.NewNetworkError(errs.SubtypeNetworkTransport, "build download request").WithCause(err) } resp, err := newFileTransferClient().Do(req) if err != nil { return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed").WithCause(err).WithRetryable() } defer resp.Body.Close() if resp.StatusCode >= 400 { io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) if resp.StatusCode >= 500 { return errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed: HTTP %d", resp.StatusCode).WithRetryable() } return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode) } saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{ ContentType: resp.Header.Get("Content-Type"), ContentLength: resp.ContentLength, }, resp.Body) if err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output").WithCause(err) } resolved, perr := rctx.FileIO().ResolvePath(out) if perr != nil || resolved == "" { resolved = out } result := map[string]interface{}{ "path": remotePath, "output": resolved, "size_bytes": saved.Size(), } rctx.OutFormat(result, nil, func(w io.Writer) { fmt.Fprintf(w, "✓ Downloaded %s → %s (%s)\n", remotePath, resolved, humanBytes(saved.Size())) }) return nil }, }
AppsFileDownload downloads a file to a local path via a signed URL。
两步:POST /apps/{app_id}/storage/file_sign 拿 signed_url(presigned,直连对象存储), 再客户端 GET signed_url 落盘到 --output(默认远端 basename)。不单设 download 接口。
var AppsFileGet = common.Shortcut{ Service: appsService, Command: "+file-get", Description: "Get a single file's metadata by path", Risk: "read", Tips: []string{ "Example: lark-cli apps +file-get --app-id <app_id> --path /1858537546760216.png", "Tip: extract a single field with --jq, e.g. -q '.size_bytes' or -q '.download_url'", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "path", Desc: "remote file path", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } _, err := requireFilePath(rctx.Str("path")) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). GET(appFileGetPath(appID)). Desc("Get Miaoda app file metadata"). Params(buildFileGetParams(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } data, err := rctx.CallAPITyped("GET", appFileGetPath(appID), buildFileGetParams(rctx), nil) if err != nil { return err } info := projectFileInfo(data) rctx.OutFormat(info, nil, func(w io.Writer) { renderFileGetPretty(w, info) }) return nil }, }
AppsFileGet gets one file's metadata by exact remote path(动词对齐 +file-list)。
GET /apps/{app_id}/storage/file?path=<path>。file 仅按 path 精确寻址,无按名寻址。 pretty 渲染 key/value:file_name / path / size(含 bytes) / type / uploaded_by(只 name) / uploaded_at / download_url(条件出现)。server created_at/created_by → uploaded_at/uploaded_by。
var AppsFileList = common.Shortcut{ Service: appsService, Command: "+file-list", Description: "List files in a Miaoda app's storage (cursor pagination)", Risk: "read", Tips: []string{ "Example: lark-cli apps +file-list --app-id <app_id>", "Tip: filter fields with --jq, e.g. -q '.data.items[].path'", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "name", Desc: "filter by exact file name"}, {Name: "path", Desc: "filter by exact remote path"}, {Name: "type", Desc: "filter by MIME type"}, {Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"}, {Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"}, {Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"}, {Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"}, {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, {Name: "page-token", Desc: "pagination cursor from previous response"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } for _, f := range []string{"uploaded-since", "uploaded-until"} { if strings.TrimSpace(rctx.Str(f)) == "" { continue } n, err := normalizeTimestamp(rctx.Str(f)) if err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: %v", f, err).WithParam("--" + f) } _ = rctx.Cmd.Flags().Set(f, n) } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). GET(appFileListPath(appID)). Desc("List Miaoda app files"). Params(buildFileListParams(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } data, err := rctx.CallAPITyped("GET", appFileListPath(appID), buildFileListParams(rctx), nil) if err != nil { return err } items := projectFileItems(data["items"]) data["items"] = items rctx.OutFormat(data, nil, func(w io.Writer) { renderFileListPretty(w, items) }) return nil }, }
AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt / --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size/--page-token。 file 域不分 dev/online,无 --env。
pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。 server 字段 created_at → 产品语义 uploaded_at。
var AppsFileQuotaGet = common.Shortcut{ Service: appsService, Command: "+file-quota-get", Description: "Get an app's file-storage usage", Risk: "read", Tips: []string{ "Example: lark-cli apps +file-quota-get --app-id <app_id>", "Tip: get just the usage percent with -q '.usage_percent'", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { _, err := requireAppID(rctx.Str("app-id")) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). GET(appFileQuotaPath(appID)). Desc("Get Miaoda app file-storage usage") }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } data, err := rctx.CallAPITyped("GET", appFileQuotaPath(appID), nil, nil) if err != nil { return err } out := projectFileQuota(data) rctx.OutFormat(out, nil, func(w io.Writer) { renderFileQuotaPretty(w, out) }) return nil }, }
AppsFileQuotaGet reports an app's file-storage usage(动词对齐 +db-quota-get)。
GET /apps/{app_id}/storage/file_quota。storage_quota_bytes / usage_percent 在配额未对接(=0)时 不输出(json 删字段、pretty 只打已用量)。
var AppsFileSign = common.Shortcut{ Service: appsService, Command: "+file-sign", Description: "Generate a temporary signed download URL for a file", Risk: "read", Tips: []string{ "Example: lark-cli apps +file-sign --app-id <app_id> --path /1858537546760216.png", "Tip: curl the signed_url directly to download.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "path", Desc: "remote file path", Required: true}, {Name: "expires-in", Type: "int", Default: "86400", Desc: "link validity in seconds (max 2592000 = 30d)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } if _, err := requireFilePath(rctx.Str("path")); err != nil { return err } if rctx.Int("expires-in") > fileSignMaxExpiresSeconds { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--expires-in exceeds the maximum of %d seconds (30d)", fileSignMaxExpiresSeconds).WithParam("--expires-in") } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). POST(appFileSignPath(appID)). Desc("Sign a temporary download URL"). Body(buildFileSignBody(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } data, err := rctx.CallAPITyped("POST", appFileSignPath(appID), nil, buildFileSignBody(rctx)) if err != nil { return err } rctx.OutFormat(data, nil, func(w io.Writer) { fmt.Fprintln(w, common.GetString(data, "signed_url")) }) return nil }, }
AppsFileSign generates a temporary signed download URL for a file。
POST /apps/{app_id}/storage/file_sign,body {path, expires_in}。 pretty 模式只打 signed_url(便于直接管道 / curl);json 返 {file_name,path,signed_url,expires_at}。
var AppsFileUpload = common.Shortcut{ Service: appsService, Command: "+file-upload", Description: "Upload a local file to an app's storage", Risk: "write", Tips: []string{ "Example: lark-cli apps +file-upload --app-id <app_id> --file ./logo.png", "Example: lark-cli apps +file-upload --app-id <app_id> --file ./report.pdf -q '.path' # print the platform-generated file path", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "file", Desc: "local file to upload (file_name = basename)", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } f := strings.TrimSpace(rctx.Str("file")) if f == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file") } st, err := rctx.FileIO().Stat(f) if err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err) } if st.IsDir() { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file") } if st.Size() > fileUploadMaxBytes { return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file") } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). POST(appFilePreUploadPath(appID)). Desc("Pre-upload → client PUT bytes → callback (3-step)"). Body(map[string]interface{}{"file_name": filepath.Base(strings.TrimSpace(rctx.Str("file")))}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } localPath := strings.TrimSpace(rctx.Str("file")) content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath) if err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err) } fileName := filepath.Base(localPath) contentType := mimeByExt(fileName) pre, err := rctx.CallAPITyped("POST", appFilePreUploadPath(appID), nil, map[string]interface{}{ "file_name": fileName, "file_size": len(content), "content_type": contentType, }) if err != nil { return err } uploadURL := common.GetString(pre, "upload_url") uploadID := common.GetString(pre, "upload_id") if uploadURL == "" || uploadID == "" { return errs.NewInternalError(errs.SubtypeInvalidResponse, "pre-upload returned no upload_url / upload_id") } etag, err := putFileBytes(rctx.Ctx(), uploadURL, content, contentType, fileName) if err != nil { return err } result, err := rctx.CallAPITyped("POST", appFileUploadCallbackPath(appID), nil, map[string]interface{}{ "upload_id": uploadID, "etag": etag, }) if err != nil { return err } info := projectFileInfo(result) rctx.OutFormat(info, nil, func(w io.Writer) { renderFileUploadPretty(w, fileName, info) }) return nil }, }
AppsFileUpload uploads a local file to an app's storage(三步直传)。
1. POST /apps/{app_id}/storage/file_pre_upload {file_name,file_size,content_type} → {upload_url,upload_id} 2. 客户端 PUT 文件字节到 presigned upload_url,取响应 ETag 3. POST /apps/{app_id}/storage/file_upload_callback {upload_id,etag} → 文件元数据 file_name 取本地 basename;path 由平台生成 16 位 ID(不可指定)。仅收 --file。
var AppsGitCredentialInit = common.Shortcut{ Service: appsService, Command: "+git-credential-init", Description: "Initialize Git credentials and a URL-scoped Git helper for an 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: "app ID", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("app-id")) == "" { return appsValidationParamError("--app-id", "--app-id is required") } if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil { return appsValidationParamError("--app-id", "%v", err).WithCause(err) } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID := strings.TrimSpace(rctx.Str("app-id")) return common.NewDryRunAPI(). GET(gitCredentialIssuePath). Desc("Issue an app Git repository PAT"). Set("mode", "api-plus-local-setup"). Set("action", "initialize_local_git_credential"). Set("app_id", appID). Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)). Set("local_effects", []string{ "save the issued PAT in the local system credential store", "write app-scoped git credential metadata", "configure a URL-scoped Git credential helper in global git config when possible", }). Params(gitCredentialIssueParams(appID)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID := strings.TrimSpace(rctx.Str("app-id")) manager := newGitCredentialManager(appID, rctx.Factory.Keychain, runtimeIssuer{rctx: rctx}) result, err := manager.Init(ctx, profileFromConfig(rctx.Config), appID) if err != nil { return gitCredentialLocalError("Initialize local app 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 AppsGitCredentialList = common.Shortcut{ Service: appsService, Command: "+git-credential-list", Description: "List local Git credentials for app repositories", Risk: "read", Tips: []string{ "Example: lark-cli apps +git-credential-list", }, Scopes: []string{}, AuthTypes: []string{"user"}, HasFormat: true, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). Desc("Preview local Git credential listing (no API call, read-only local state)."). Set("mode", "local-read-only"). Set("action", "list_local_git_credentials"). Set("storage_root", filepath.Join(core.GetConfigDir(), storageRoot)). Set("reads", []string{ "scan app-scoped git credential metadata under the CLI config directory", "derive per-app repository URLs and local credential status from local metadata", }) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now) if err != nil { return gitCredentialLocalError("List local app 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 }, }
var AppsGitCredentialRemove = common.Shortcut{ Service: appsService, Command: "+git-credential-remove", Description: "Remove local Git credentials and the URL-scoped Git helper for an 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: "app ID", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("app-id")) == "" { return appsValidationParamError("--app-id", "--app-id is required") } if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil { return appsValidationParamError("--app-id", "%v", err).WithCause(err) } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID := strings.TrimSpace(rctx.Str("app-id")) return common.NewDryRunAPI(). Desc("Preview local Git credential cleanup (no API call; would clean up local-only state)."). Set("mode", "local-cleanup-only"). Set("action", "remove_local_git_credential"). Set("app_id", appID). Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)). Set("effects", []string{ "read app-scoped git credential metadata", "remove the saved PAT from the local system credential store", "remove the app-scoped Git helper from global git config when present", "delete the local metadata record after cleanup succeeds", }) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID := strings.TrimSpace(rctx.Str("app-id")) manager := newGitCredentialManager(appID, rctx.Factory.Keychain, nil) result, err := manager.Remove(ctx, profileFromConfig(rctx.Config), appID) if err != nil { return gitCredentialLocalError("Remove local app 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 AppsHTMLPublish = common.Shortcut{ Service: appsService, Command: "+html-publish", Description: "Publish HTML to an 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, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "path", Desc: "path to HTML file or directory", Required: true}, {Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / .aws/credentials / etc. in the publish payload)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("app-id")) == "" { return appsValidationParamError("--app-id", "--app-id is required") } path := strings.TrimSpace(rctx.Str("path")) if path == "" { return appsValidationParamError("--path", "--path is required") } if rctx.Bool("allow-sensitive") { return nil } candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path) if err != nil { return nil } var hits []string for _, c := range candidates { if isSensitiveCandidate(path, c) { hits = append(hits, c.RelPath) } } if len(hits) > 0 { return sensitiveCandidatesError(hits) } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID := strings.TrimSpace(rctx.Str("app-id")) path := strings.TrimSpace(rctx.Str("path")) dry := common.NewDryRunAPI() dry.Desc("Upload tar.gz + publish HTML (multipart, returns url)") dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))). Set("content_type", "multipart/form-data") candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path) if err != nil { dry.Set("path_error", err.Error()) return dry } if err := ensureIndexHTML(candidates); err != nil { dry.Set("validation_error", err.Error()) } if hits := oversizeHTMLFiles(candidates); len(hits) > 0 { dry.Set("oversize_html", hits) } dry.Set("file_count", len(candidates)) var totalSize int64 names := make([]string, 0, len(candidates)) for _, c := range candidates { totalSize += c.Size names = append(names, c.RelPath) } dry.Set("total_size_bytes", totalSize) dry.Set("files", names) if rctx.Bool("allow-sensitive") { var waived []string for _, c := range candidates { if isSensitiveCandidate(path, c) { waived = append(waived, c.RelPath) } } if len(waived) > 0 { dry.Set("sensitive_waived", waived) dry.Set("sensitive_waived_summary", fmt.Sprintf("%d credential file(s) included because --allow-sensitive is set", len(waived))) } } return dry }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { spec := appsHTMLPublishSpec{ AppID: strings.TrimSpace(rctx.Str("app-id")), Path: strings.TrimSpace(rctx.Str("path")), } client := appsHTMLPublishAPI{runtime: rctx} out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec) if err != nil { return err } rctx.OutFormat(out, nil, func(w io.Writer) { if url, ok := out["url"].(string); ok && url != "" { fmt.Fprintf(w, "url: %s\n", url) } }) return nil }, }
AppsHTMLPublish packs --path as tar.gz and uploads + publishes via one multipart POST.
var AppsInit = common.Shortcut{ Service: appsService, Command: "+init", Description: "Initialize an 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", }, Scopes: []string{}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "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 appsValidationParamError("--app-id", "--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 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) { if existing, e := ensureInitDirMatchesApp(dir, appID); e != nil { if existing != "" { dry.Set("app_id_mismatch", existing) } dry.Set("dir_error", e.Error()) } else { 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, }
AppsInit initializes an app's code and local development environment.
var AppsList = common.Shortcut{ Service: appsService, Command: "+list", Description: "List apps visible to 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, 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"}, }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET(apiBasePath + "/apps"). Desc("List apps"). Params(buildAppsListParams(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { data, err := rctx.CallAPITyped("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil) if err != nil { return err } rawItems, _ := data["items"].([]interface{}) items := make([]interface{}, 0, len(rawItems)) for _, item := range rawItems { m, ok := item.(map[string]interface{}) if !ok { items = append(items, item) continue } out := make(map[string]interface{}, len(m)) for k, v := range m { if k == "icon_url" || k == "created_at" { continue } out[k] = v } items = append(items, out) } data["items"] = items rctx.OutFormat(data, nil, func(w io.Writer) { rows := make([]map[string]interface{}, 0, len(items)) for _, item := range items { m, ok := item.(map[string]interface{}) if !ok { 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"], }) } output.PrintTable(w, rows) }) return nil }, }
AppsList lists apps visible to 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).
var AppsLogGet = common.Shortcut{ Service: appsService, Command: "+log-get", Description: "Get one online app log by log ID", Risk: "read", Tips: []string{ "Example: lark-cli apps +log-get --app-id <app_id> --log-id <log_id>", "Tip: +log-get searches online logs with limit=1; use +log-list first if the log ID is unknown.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true}, {Name: "log-id", Desc: "log ID to fetch", Required: true}, {Name: appsEnvironmentFlag, Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, }, 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("log-id")) == "" { return appsValidationParamError("--log-id", "--log-id is required") } return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag)) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). POST(logSearchPath(rctx.Str("app-id"))). Desc("Search online app logs by log ID"). Body(buildLogGetSearchBody(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, _ := requireAppID(rctx.Str("app-id")) data, err := callLogGetSearch(rctx, appID, buildLogGetSearchBody(rctx)) if err != nil { return withAppsHint(err, appIDListHint) } out := normalizeLogSearchResponse(data) if len(out.Items) == 0 { return appsFailedPreconditionParamError("--log-id", "log not found"). WithHint("verify --log-id and --environment online") } log := out.Items[0] enrichLogSourceStack(rctx, appID, log) rctx.OutFormat(log, nil, func(w io.Writer) { appsPrintSchemaTable(w, appsProjectRows([]map[string]interface{}{logSummaryRow(log)}, logSummarySchema), logSummarySchema) }) return nil }, }
AppsLogGet fetches one log by log ID through the search_logs endpoint.
var AppsLogList = common.Shortcut{ Service: appsService, Command: "+log-list", Description: "Search online app logs with observability filters", Risk: "read", Tips: []string{ "Example: lark-cli apps +log-list --app-id <app_id> --level error --keyword timeout --since 1h", "Tip: use --page-token from the response to fetch the next page.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true}, {Name: appsEnvironmentFlag, Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, {Name: "level", Type: "string_array", Desc: "log level filter; repeatable, one of DEBUG, INFO, WARN, ERROR (case-insensitive)"}, {Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"}, {Name: "keyword", Desc: "keyword filter applied by the log search backend"}, {Name: "module", Desc: "module name filter"}, {Name: "user-id", Desc: "end user ID filter"}, {Name: "page", Desc: "frontend page or route filter"}, {Name: "api", Desc: "API path/name filter"}, {Name: "min-duration", Type: "int", Desc: "minimum duration in milliseconds; must be non-negative"}, {Name: "max-duration", Type: "int", Desc: "maximum duration in milliseconds; must be non-negative and >= --min-duration"}, {Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", defaultAppsPageSize), Desc: "page size, 1..100"}, {Name: "page-token", Desc: "pagination cursor from a previous log search response"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } _, err := buildLogSearchBody(rctx) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { body, _ := buildLogSearchBody(rctx) return common.NewDryRunAPI(). POST(logSearchPath(rctx.Str("app-id"))). Desc("Search online app logs"). Body(body) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, _ := requireAppID(rctx.Str("app-id")) body, err := buildLogSearchBody(rctx) if err != nil { return err } data, err := rctx.CallAPITyped("POST", logSearchPath(appID), nil, body) if err != nil { return withAppsHint(err, appIDListHint) } out := normalizeLogSearchResponse(data) rctx.OutFormat(out, nil, func(w io.Writer) { appsPrintSchemaTable(w, appsProjectRows(logListRows(out.Items), logSummarySchema), logSummarySchema) }) return nil }, }
AppsLogList searches online app logs with observability filters.
var AppsMetricList = common.Shortcut{ Service: appsService, Command: "+metric-list", Description: "List online app request, latency, CPU, and memory metrics", Risk: "read", Tips: []string{ "Example: lark-cli apps +metric-list --app-id <app_id> --metric requests --series total --since 1d", "Tip: metric timestamps use seconds; use +analytics-list for PV/UV-style analytics.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online metrics should be listed", Required: true}, {Name: appsEnvironmentFlag, Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"}, {Name: "metric", Desc: "metric family to list", Required: true, Enum: []string{"requests", "latency", "cpu", "memory"}}, {Name: "series", Desc: "metric series within the family, such as total/error or p50/p99"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, {Name: "page", Type: "string_array", Desc: "frontend page or route filter; repeatable"}, {Name: "api", Type: "string_array", Desc: "API path/name filter; repeatable"}, {Name: "down-sample", Default: defaultAppsMetricDownSample, Desc: "metric down-sample interval", Enum: []string{"1m", "1h", "1d"}}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } _, _, _, _, err := buildMetricListBody(rctx) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { body, _, _, _, _ := buildMetricListBody(rctx) return common.NewDryRunAPI(). POST(metricListPath(rctx.Str("app-id"))). Desc("List online app metrics"). Body(body) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, _ := requireAppID(rctx.Str("app-id")) body, names, labels, fillZero, err := buildMetricListBody(rctx) if err != nil { return err } data, err := rctx.CallAPITyped("POST", metricListPath(appID), nil, body) if err != nil { return withAppsHint(err, appIDListHint) } out := observabilitySeriesOutput{ Items: normalizeMetricSeries(data, names, labels, fillZero), HasMore: false, } rctx.OutFormat(out, nil, func(w io.Writer) { rows := observabilitySeriesRows(out.Items) sortObservabilityRowsDesc(rows, "timestamp") rows = filterObservabilityRowsWithTime(rows, "timestamp") appsPrintSchemaTable(w, rows, metricSeriesSchema(labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "latency")) }) return nil }, }
AppsMetricList lists online app observability metrics.
var AppsOpenAPIKeyCreate = common.Shortcut{ Service: appsService, Command: "+openapi-key-create", Description: "Create an open API key (returns the raw secret once)", Risk: "write", Tips: []string{ "Example: lark-cli apps +openapi-key-create --app-id <app_id> --name partner-test", "Example: lark-cli apps +openapi-key-create --app-id <app_id> --name orders-readonly --scope-api 'GET /openapi/orders'", "Example: lark-cli apps +openapi-key-create --app-id <app_id> --name full-access --scope-all", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "name", Desc: "API key name", Required: true}, {Name: "scope-all", Type: "bool", Desc: "grant access to all /openapi/** routes (request_scope.allow_all)"}, {Name: "scope-api", Type: "string_array", Desc: "grant one route, repeatable: 'METHOD /openapi/path' (from the app's docs/openapi.json)"}, {Name: "scope", Desc: "advanced: raw JSON for config.request_scope (mutually exclusive with --scope-all/--scope-api)"}, {Name: "allow-preview", Type: "bool", Desc: "allow preview-env access (config.is_allow_access_preview)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if err := oapiKeyValidateAppID(rctx); err != nil { return err } if strings.TrimSpace(rctx.Str("name")) == "" { return appsValidationParamError("--name", "--name is required"). WithHint("provide a human-readable key name, e.g. --name partner-readonly") } return oapiKeyValidateScopeFlags(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID := strings.TrimSpace(rctx.Str("app-id")) body, _ := buildOpenAPIKeyCreateBody(rctx) return common.NewDryRunAPI(). POST(fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID))). Desc("Create open API key"). Body(body) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID := strings.TrimSpace(rctx.Str("app-id")) body, err := buildOpenAPIKeyCreateBody(rctx) if err != nil { return appsValidationParamError("--scope", "invalid scope: %v", err). WithHint("--scope must be valid JSON for config.request_scope; or use --scope-all / --scope-api") } path := fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID)) data, err := rctx.CallAPITyped("POST", path, nil, body) if err != nil { return withAppsHint(err, appIDListHint) } return outputIssuedKey(rctx, data) }, }
AppsOpenAPIKeyCreate creates an open API key. The raw secret is returned ONCE.
var AppsOpenAPIKeyDelete = common.Shortcut{ Service: appsService, Command: "+openapi-key-delete", Description: "Delete an open API key (irreversible; prefer +openapi-key-disable)", Risk: "high-risk-write", Tips: []string{ "Example: lark-cli apps +openapi-key-delete --app-id <app_id> --key-id <key_id> --yes", "Preview: add --dry-run to see the request without deleting", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "key-id", Desc: "API key ID", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI().DELETE(oapiKeyItemURL(rctx)).Desc("Delete open API key") }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { id := strings.TrimSpace(rctx.Str("key-id")) if _, err := rctx.CallAPITyped("DELETE", oapiKeyItemURL(rctx), nil, nil); err != nil { return withAppsHint(err, oapiKeyNotFoundHint(rctx)) } out := map[string]interface{}{"api_key_id": id, "deleted": true} rctx.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintf(w, "deleted API key ID: %s\n", id) }) return nil }, }
AppsOpenAPIKeyDelete permanently deletes an open API key (irreversible).
var AppsOpenAPIKeyDisable = common.Shortcut{ Service: appsService, Command: "+openapi-key-disable", Description: "Disable an open API key (minimal safety brake)", Risk: "write", Tips: []string{"Example: lark-cli apps +openapi-key-disable --app-id <app_id> --key-id <key_id>"}, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "key-id", Desc: "API key ID", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI().PATCH(oapiKeyItemURL(rctx)).Desc("Disable open API key").Body(openAPIKeyStatusBody(keyStatusDisable)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { return execOpenAPIKeyStatus(rctx, keyStatusDisable) }, }
AppsOpenAPIKeyDisable disables (status=0) an open API key — the minimal safety brake.
var AppsOpenAPIKeyEnable = common.Shortcut{ Service: appsService, Command: "+openapi-key-enable", Description: "Enable an open API key", Risk: "write", Tips: []string{"Example: lark-cli apps +openapi-key-enable --app-id <app_id> --key-id <key_id>"}, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "key-id", Desc: "API key ID", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI().PATCH(oapiKeyItemURL(rctx)).Desc("Enable open API key").Body(openAPIKeyStatusBody(keyStatusEnable)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { return execOpenAPIKeyStatus(rctx, keyStatusEnable) }, }
AppsOpenAPIKeyEnable enables (status=1) an open API key.
var AppsOpenAPIKeyGet = common.Shortcut{ Service: appsService, Command: "+openapi-key-get", Description: "Get an open API key detail (secret redacted)", Risk: "read", Tips: []string{ "Example: lark-cli apps +openapi-key-get --app-id <app_id> --key-id <key_id>", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "key-id", Desc: "API key ID", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET(oapiKeyItemURL(rctx)). Desc("Get open API key detail") }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { data, err := rctx.CallAPITyped("GET", oapiKeyItemURL(rctx), nil, nil) if err != nil { return withAppsHint(err, oapiKeyNotFoundHint(rctx)) } return outputRedactedInfo(rctx, data) }, }
AppsOpenAPIKeyGet returns one open API key's detail (redacted).
var AppsOpenAPIKeyList = common.Shortcut{ Service: appsService, Command: "+openapi-key-list", Description: "List an app's open API keys (secrets redacted)", Risk: "read", Tips: []string{ "Example: lark-cli apps +openapi-key-list --app-id <app_id>", "Example: lark-cli apps +openapi-key-list --app-id <app_id> --limit 10", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "limit", Type: "int", Desc: "page size (server default if omitted)"}, {Name: "offset", Type: "int", Desc: "page offset"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateAppID(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID := strings.TrimSpace(rctx.Str("app-id")) return common.NewDryRunAPI(). GET(fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID))). Desc("List open API keys"). Params(buildOpenAPIKeyListParams(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID := strings.TrimSpace(rctx.Str("app-id")) path := fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID)) data, err := rctx.CallAPITyped("GET", path, buildOpenAPIKeyListParams(rctx), nil) if err != nil { return withAppsHint(err, appIDListHint) } infos := common.GetSlice(data, "infos") redacted := make([]interface{}, 0, len(infos)) for _, it := range infos { if m, ok := it.(map[string]interface{}); ok { redacted = append(redacted, redactKeyInfo(m)) } else { redacted = append(redacted, it) } } out := map[string]interface{}{"infos": redacted} rctx.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintf(w, "%d key(s)\n", len(redacted)) for _, it := range redacted { if m, ok := it.(map[string]interface{}); ok { fmt.Fprintf(w, "- %v %v %v\n", m["api_key_id"], m["name"], m["key_preview"]) } } }) return nil }, }
AppsOpenAPIKeyList lists an app's open API keys (redacted; raw secret never shown).
var AppsOpenAPIKeyReset = common.Shortcut{ Service: appsService, Command: "+openapi-key-reset", Description: "Reset (rotate) an open API key; returns a new raw secret once", Risk: "high-risk-write", Tips: []string{ "Example: lark-cli apps +openapi-key-reset --app-id <app_id> --key-id <key_id> --yes", "Preview: add --dry-run to see the request without rotating", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "key-id", Desc: "API key ID", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI().POST(oapiKeyRefreshURL(rctx)).Desc("Reset (rotate) open API key") }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { data, err := rctx.CallAPITyped("POST", oapiKeyRefreshURL(rctx), nil, nil) if err != nil { return withAppsHint(err, oapiKeyNotFoundHint(rctx)) } return outputIssuedKey(rctx, data) }, }
AppsOpenAPIKeyReset rotates (refreshes) an open API key, returning a new raw secret ONCE.
var AppsOpenAPIKeyUpdate = common.Shortcut{ Service: appsService, Command: "+openapi-key-update", Description: "Update an open API key's name and/or scope", Risk: "write", Tips: []string{ "Example: lark-cli apps +openapi-key-update --app-id <app_id> --key-id <key_id> --name partner-prod", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "key-id", Desc: "API key ID", Required: true}, {Name: "name", Desc: "new name"}, {Name: "scope-all", Type: "bool", Desc: "grant access to all /openapi/** routes (request_scope.allow_all)"}, {Name: "scope-api", Type: "string_array", Desc: "grant one route, repeatable: 'METHOD /openapi/path' (from the app's docs/openapi.json)"}, {Name: "scope", Desc: "advanced: raw JSON for config.request_scope (mutually exclusive with --scope-all/--scope-api)"}, {Name: "allow-preview", Type: "bool", Desc: "allow preview-env access (config.is_allow_access_preview)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if err := oapiKeyValidateKeyID(rctx); err != nil { return err } if strings.TrimSpace(rctx.Str("name")) == "" && !rctx.Changed("scope-all") && len(rctx.StrArray("scope-api")) == 0 && strings.TrimSpace(rctx.Str("scope")) == "" && !rctx.Changed("allow-preview") { return appsValidationParamError("--name", "at least one of --name / --scope-all / --scope-api / --scope / --allow-preview is required"). WithHint("pass at least one of --name / --scope-all / --scope-api / --scope / --allow-preview") } return oapiKeyValidateScopeFlags(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { body, _ := buildOpenAPIKeyUpdateBody(rctx) return common.NewDryRunAPI(). PATCH(oapiKeyItemURL(rctx)). Desc("Update open API key"). Body(body) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { body, err := buildOpenAPIKeyUpdateBody(rctx) if err != nil { return appsValidationParamError("--scope", "invalid scope: %v", err) } data, err := rctx.CallAPITyped("PATCH", oapiKeyItemURL(rctx), nil, body) if err != nil { return withAppsHint(err, oapiKeyNotFoundHint(rctx)) } return outputRedactedInfo(rctx, data) }, }
AppsOpenAPIKeyUpdate updates an open API key's name and/or config (not status).
var AppsPluginInstall = common.Shortcut{ Service: appsService, Command: "+plugin-install", Description: "Install a plugin package (download, extract, update package.json)", Risk: "write", ConditionalScopes: []string{"spark:app:read"}, Scopes: []string{}, AuthTypes: []string{"user"}, Tips: []string{ "Run in project root (like npm); does NOT take --app-id", "Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate (install or update to latest)", "Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0 (install or update to specific version)", "Example: lark-cli apps +plugin-install (batch install all declared plugins from package.json actionPlugins)", }, Flags: []common.Flag{ {Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"}, {Name: "version", Desc: "plugin version (e.g. 1.0.0); omit to install latest"}, {Name: "file", Desc: "install from a local .tgz file (dev/test only)", Hidden: true}, }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { key := strings.TrimSpace(rctx.Str("name")) if key == "" { return common.NewDryRunAPI(). POST(apiBasePath+"/plugin/versions/batch_query"). Desc("Batch-install all declared plugins from package.json actionPlugins"). Set("request_body", `{"plugin_keys": [<from actionPlugins>], "latest_only": false}`) } version := strings.TrimSpace(rctx.Str("version")) isLatest := version == "" || version == "latest" desc := fmt.Sprintf("Query version for %s, then download .tgz", key) if isLatest { desc = fmt.Sprintf("Install latest version of %s (omit --version to install latest)", key) } return common.NewDryRunAPI(). POST(apiBasePath+"/plugin/versions/batch_query"). Desc(desc). Set("request_body", fmt.Sprintf(`{"plugin_keys": ["%s"], "latest_only": %v}`, key, isLatest)). Set("download_body", fmt.Sprintf(`{"plugin_key": "%s", "plugin_version": "%s"}`, key, version)) }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { projectPath, err := pluginResolveProjectPath("") if err != nil { return err } if key := strings.TrimSpace(rctx.Str("name")); key != "" { if err := validatePluginKey(key); err != nil { return err } } return pluginCheckProjectDir(projectPath) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { projectPath, err := pluginResolveProjectPath("") if err != nil { return err } if localTgz := strings.TrimSpace(rctx.Str("file")); localTgz != "" { return pluginInstallLocal(rctx, projectPath, localTgz) } key := strings.TrimSpace(rctx.Str("name")) if key == "" { return pluginInstallAll(ctx, rctx, projectPath) } version := strings.TrimSpace(rctx.Str("version")) return pluginInstallOne(ctx, rctx, projectPath, key, version) }, }
AppsPluginInstall downloads a plugin package from the registry, extracts it to node_modules, and updates package.json actionPlugins.
Without --name it batch-installs all plugins declared in actionPlugins that are not yet present in node_modules.
var AppsPluginList = common.Shortcut{ Service: appsService, Command: "+plugin-list", Description: "List locally installed plugin packages and their installation status", Risk: "read", Scopes: []string{}, Tips: []string{ "Run in project root (like npm); does NOT take --app-id", "Example: lark-cli apps +plugin-list", "Example: lark-cli apps +plugin-list --format pretty", }, Flags: []common.Flag{}, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). Desc("List declared plugin packages and installation status"). Set("action", "list"). Set("source", "package.json actionPlugins + node_modules") }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { projectPath, err := pluginResolveProjectPath("") if err != nil { return err } return pluginCheckProjectDir(projectPath) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { projectPath, err := pluginResolveProjectPath("") if err != nil { return err } pkg, err := pluginReadPackageJSON(projectPath) if err != nil { return err } declared := pluginGetActionPlugins(pkg) plugins := make([]interface{}, 0, len(declared)) for key, version := range declared { installed := pluginInstalledVersion(projectPath, key) status := "declared_not_installed" if installed != "" { status = "installed" } plugins = append(plugins, map[string]interface{}{ "key": key, "version": version, "status": status, }) } data := map[string]interface{}{"plugins": plugins} rctx.OutFormat(data, &output.Meta{Count: len(plugins)}, func(w io.Writer) { if len(plugins) == 0 { fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.") return } rows := make([]map[string]interface{}, 0, len(plugins)) for _, p := range plugins { rows = append(rows, p.(map[string]interface{})) } output.PrintTable(w, rows) }) return nil }, }
AppsPluginList lists plugin packages declared in package.json actionPlugins, cross-referencing with node_modules to report installation status.
var AppsPluginUninstall = common.Shortcut{ Service: appsService, Command: "+plugin-uninstall", Description: "Uninstall a plugin package (remove from node_modules and package.json)", Risk: "write", Scopes: []string{}, Tips: []string{ "Run in project root (like npm); does NOT take --app-id", "Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate", }, Flags: []common.Flag{ {Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate)", Required: true}, }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { key := strings.TrimSpace(rctx.Str("name")) return common.NewDryRunAPI(). Desc("Uninstall plugin package (remove from node_modules and package.json)"). Set("action", "uninstall"). Set("plugin_key", key). Set("remove_dir", fmt.Sprintf("node_modules/%s", key)). Set("update_file", "package.json actionPlugins") }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if key := strings.TrimSpace(rctx.Str("name")); key == "" { return appsValidationParamError("--name", "--name is required") } else if err := validatePluginKey(key); err != nil { return err } projectPath, err := pluginResolveProjectPath("") if err != nil { return err } return pluginCheckProjectDir(projectPath) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { key := strings.TrimSpace(rctx.Str("name")) projectPath, err := pluginResolveProjectPath("") if err != nil { return err } if err := pluginCheckDependentInstances(projectPath, key); err != nil { return err } pkgDir, err := secureModulePath(projectPath, key) if err != nil { return err } if err := os.RemoveAll(pkgDir); err != nil { return appsFileIOError(err, "cannot remove %s", pkgDir) } pkg, err := pluginReadPackageJSON(projectPath) if err != nil { return err } pluginRemoveActionPlugin(pkg, key) if err := pluginWritePackageJSON(projectPath, pkg); err != nil { return appsFileIOError(err, "cannot update package.json") } result := map[string]interface{}{ "key": key, "removed": true, } rctx.OutFormat(result, nil, func(w io.Writer) { fmt.Fprintf(w, "✓ Plugin uninstalled: %s\n", key) }) return nil }, }
AppsPluginUninstall removes a plugin package from node_modules and its entry from package.json actionPlugins.
var AppsReleaseCreate = common.Shortcut{ Service: appsService, Command: "+release-create", Description: "Create a release for an 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: "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 appsValidationParamError("--app-id", "--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 }, }
AppsReleaseCreate creates a release for an app.
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: "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 appsValidationParamError("--app-id", "--app-id is required") } if strings.TrimSpace(rctx.Str("release-id")) == "" { return appsValidationParamError("--release-id", "--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 if el, ok := data["error_logs"]; ok { out["error_logs"] = el } } 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 }, }
AppsReleaseGet fetches a single release's detail by release ID.
var AppsReleaseList = common.Shortcut{ Service: appsService, Command: "+release-list", Description: "List an 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: "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 appsValidationParamError("--app-id", "--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 }, }
AppsReleaseList lists an app's release history (most recent first).
var AppsSessionCreate = common.Shortcut{ Service: appsService, Command: "+session-create", Description: "Create a session under an 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 appsValidationParamError("--app-id", "--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 an 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 }, }
AppsSessionCreate creates a new session under an existing app.
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 appsValidationParamError("--app-id", "--app-id is required") } if strings.TrimSpace(rctx.Str("session-id")) == "" { return appsValidationParamError("--session-id", "--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 }, }
AppsSessionGet reads a session's current status, queued turns, and latest turn. Single-shot: the caller drives polling using next_poll_after_ms.
var AppsSessionList = common.Shortcut{ Service: appsService, Command: "+session-list", Description: "List sessions under an 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 appsValidationParamError("--app-id", "--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 an 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 }, }
AppsSessionList lists sessions under an app (cursor pagination, single page).
var AppsSessionMessagesList = common.Shortcut{ Service: appsService, Command: "+session-messages-list", Description: "List the reply messages of a session turn (page_token pagination)", Risk: "read", Tips: []string{ "Example: lark-cli apps +session-messages-list --app-id <app_id> --session-id <session_id> --turn-id <turn_id>", "Tip: turn_id comes from `+session-get` latest_turn.turn_id; page with --page-token <next_page_token>", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID"}, {Name: "session-id", Desc: "session ID"}, {Name: "turn-id", Desc: "turn ID (from +session-get latest_turn.turn_id)"}, {Name: "page-token", Desc: "pagination token from previous response next_page_token (omit for first page)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("app-id")) == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id") } if strings.TrimSpace(rctx.Str("session-id")) == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--session-id is required").WithParam("--session-id") } if strings.TrimSpace(rctx.Str("turn-id")) == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--turn-id is required").WithParam("--turn-id") } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET(replyMessagePath(rctx.Str("app-id"), rctx.Str("session-id"), rctx.Str("turn-id"))). Desc("List the reply messages of a session turn"). Params(buildSessionMessagesListParams(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { data, err := rctx.CallAPITyped("GET", replyMessagePath(rctx.Str("app-id"), rctx.Str("session-id"), rctx.Str("turn-id")), buildSessionMessagesListParams(rctx), nil) if err != nil { return withAppsHint(err, sessionMessagesListHint) } messages, _ := data["messages"].([]interface{}) rctx.OutFormat(data, nil, func(w io.Writer) { rows := make([]map[string]interface{}, 0, len(messages)) for _, item := range messages { m, ok := item.(map[string]interface{}) if !ok { continue } rows = append(rows, map[string]interface{}{ "message_id": m["message_id"], "role": m["role"], "content": m["content"], }) } output.PrintTable(w, rows) fmt.Fprintf(w, "next_page_token: %v has_more: %v\n", data["next_page_token"], data["has_more"]) }) return nil }, }
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 appsValidationParamError("--app-id", "--app-id is required") } if strings.TrimSpace(rctx.Str("session-id")) == "" { return appsValidationParamError("--session-id", "--session-id is required") } if strings.TrimSpace(rctx.Str("turn-id")) == "" { return appsValidationParamError("--turn-id", "--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 }, }
AppsSessionStop interrupts the RUNNING turn of a session. No-op if the turn is queued or already finished. Does not close the session.
var AppsTraceGet = common.Shortcut{ Service: appsService, Command: "+trace-get", Description: "Get one online app trace by trace ID", Risk: "read", Tips: []string{ "Example: lark-cli apps +trace-get --app-id <app_id> --trace-id <trace_id>", "Tip: use +trace-list first if the trace ID is unknown.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online trace should be fetched", Required: true}, {Name: appsEnvironmentFlag, Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, {Name: "trace-id", Desc: "trace ID to fetch", Required: true}, }, 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("trace-id")) == "" { return appsValidationParamError("--trace-id", "--trace-id is required") } return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag)) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). POST(traceGetPath(rctx.Str("app-id"))). Desc("Get online app trace by trace ID"). Body(buildTraceGetBody(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, _ := requireAppID(rctx.Str("app-id")) data, err := rctx.CallAPITyped("POST", traceGetPath(appID), nil, buildTraceGetBody(rctx)) if err != nil { return withAppsHint(err, appIDListHint) } trace := normalizeTraceDetail(data) rctx.OutFormat(trace, nil, func(w io.Writer) { appsPrintSchemaTable(w, appsProjectRows([]map[string]interface{}{traceDetailSummary(trace)}, traceSummarySchema), traceSummarySchema) }) return nil }, }
AppsTraceGet fetches one online app trace by trace ID.
var AppsTraceList = common.Shortcut{ Service: appsService, Command: "+trace-list", Description: "Search online app traces with observability filters", Risk: "read", Tips: []string{ "Example: lark-cli apps +trace-list --app-id <app_id> --trace-id <trace_id>", "Tip: use --page-token from the response to fetch the next page.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online traces should be searched", Required: true}, {Name: appsEnvironmentFlag, Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, {Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"}, {Name: "root-span", Desc: "root span keyword filter applied by the trace search backend"}, {Name: "user-id", Desc: "end user ID filter"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, {Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", defaultAppsPageSize), Desc: "page size, 1..100"}, {Name: "page-token", Desc: "pagination cursor from a previous trace search response"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } _, err := buildTraceSearchBody(rctx) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { body, _ := buildTraceSearchBody(rctx) return common.NewDryRunAPI(). POST(traceSearchPath(rctx.Str("app-id"))). Desc("Search online app traces"). Body(body) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, _ := requireAppID(rctx.Str("app-id")) body, err := buildTraceSearchBody(rctx) if err != nil { return err } data, err := rctx.CallAPITyped("POST", traceSearchPath(appID), nil, body) if err != nil { return withAppsHint(err, appIDListHint) } out := normalizeTraceSearchResponse(data) rctx.OutFormat(out, nil, func(w io.Writer) { appsPrintSchemaTable(w, appsProjectRows(traceListRows(out.Items), traceSummarySchema), traceSummarySchema) }) return nil }, }
AppsTraceList searches online app traces with observability filters.
var AppsUpdate = common.Shortcut{ Service: appsService, Command: "+update", Description: "Partially update an 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, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "name", Desc: "new app display name"}, {Name: "description", Desc: "new app description"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("app-id")) == "" { return appsValidationParamError("--app-id", "--app-id is required") } body := buildAppsUpdateBody(rctx) if len(body) == 0 { return appsValidationError("provide at least one of --name or --description"). WithParams( appsInvalidParam("--name", "provide at least one of --name or --description"), appsInvalidParam("--description", "provide at least one of --name or --description"), ) } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID := strings.TrimSpace(rctx.Str("app-id")) return common.NewDryRunAPI(). PATCH(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))). Desc("Update an app"). Body(buildAppsUpdateBody(rctx)) }, 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)) if err != nil { return withAppsHint(err, appIDListHint) } rctx.OutFormat(data, nil, func(w io.Writer) { fmt.Fprintf(w, "updated: %s\n", common.GetString(data, "app", "app_id")) }) return nil }, }
AppsUpdate partially updates an app's name / description.
Functions ¶
func Delete ¶ added in v1.0.51
Delete removes the file under (appID, key). A missing file is not an error.
func InstallOnApps ¶ added in v1.0.51
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 List ¶ added in v1.0.51
List returns the keys stored under appID, skipping subdirectories and names that fail to unescape or validate after decoding. A missing app directory yields an empty list.
func Read ¶ added in v1.0.51
Read returns the bytes stored under (appID, key). A missing file returns (nil, nil). Content is opaque — callers own the format. Note: an empty stored value is indistinguishable from a missing key (both yield nil), so this store is unsuitable as an existence flag.
func Write ¶ added in v1.0.51
Write atomically stores data under (appID, key): file 0600, dir 0700. It is a create-or-replace upsert for that key; content is written verbatim in plaintext. 0600 only guards against other local OS users — it does not protect against this user's processes, backups, or synced folders. appID and key are opaque strings: any "/" is escaped into a single path segment, never treated as a directory separator.
Types ¶
This section is empty.
Source Files
¶
- apps_access_scope_get.go
- apps_access_scope_set.go
- apps_analytics.go
- apps_chat.go
- apps_create.go
- apps_db_audit_list.go
- apps_db_audit_set.go
- apps_db_audit_status.go
- apps_db_changelog_list.go
- apps_db_data_export.go
- apps_db_data_import.go
- apps_db_env_create.go
- apps_db_env_migrate.go
- apps_db_execute.go
- apps_db_quota_get.go
- apps_db_recovery.go
- apps_db_table_get.go
- apps_db_table_list.go
- apps_env.go
- apps_env_pull.go
- apps_errors.go
- apps_file_delete.go
- apps_file_download.go
- apps_file_get.go
- apps_file_list.go
- apps_file_quota_get.go
- apps_file_sign.go
- apps_file_upload.go
- apps_html_publish.go
- apps_init.go
- apps_list.go
- apps_logs.go
- apps_metrics.go
- apps_observability_common.go
- apps_openapi_key_common.go
- apps_openapi_key_create.go
- apps_openapi_key_delete.go
- apps_openapi_key_disable.go
- apps_openapi_key_enable.go
- apps_openapi_key_get.go
- apps_openapi_key_list.go
- apps_openapi_key_reset.go
- apps_openapi_key_update.go
- apps_output_schema.go
- apps_plugin_install.go
- apps_plugin_list.go
- apps_plugin_uninstall.go
- apps_release_common.go
- apps_release_create.go
- apps_release_get.go
- apps_release_list.go
- apps_session_create.go
- apps_session_get.go
- apps_session_list.go
- apps_session_messages_list.go
- apps_session_stop.go
- apps_traces.go
- apps_update.go
- command_runner.go
- common.go
- db_common.go
- file_common.go
- git_credential.go
- git_credential_storage.go
- html_publish_client.go
- html_publish_tarball.go
- plugin_common.go
- sensitive_paths.go
- shortcuts.go
- storage.go
- walk_html_publish_candidates.go