apps

package
v1.0.62 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 1, 2026 License: MIT Imports: 43 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
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 数组)。

View Source
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).

View Source
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.

View Source
var AppsChat = common.Shortcut{
	Service:     appsService,
	Command:     "+chat",
	Description: "Send a message to a session to start/continue a conversation",
	Risk:        "write",
	Tips: []string{
		`Example: lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message "做一个待办清单页面"`,
		`Example: lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message "把首页标题改为 我的待办"`,
	},
	Scopes:    []string{"spark:app:write"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{
		{Name: "app-id", Desc: "app ID", Required: true},
		{Name: "session-id", Desc: "session ID", Required: true},
		{Name: "message", Desc: "user message text", Required: true},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		if strings.TrimSpace(rctx.Str("app-id")) == "" {
			return 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.

View 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.

View Source
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}。

View Source
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。

View Source
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(服务端不再返该字段)。

View Source
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、多表表格。

View Source
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。

View Source
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。

View Source
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。

View Source
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 确认关卡。

View Source
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。

View Source
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。

View Source
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") 取。

View Source
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 始终输出。

View Source
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。

View Source
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。

View Source
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 / 无表格包装)。
View Source
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 友好格式化。

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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 信封语义)。

View Source
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 接口。

View Source
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。

View Source
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。

View Source
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 只打已用量)。

View Source
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}。

View Source
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。

View Source
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
	},
}
View Source
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
	},
}
View Source
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
	},
}
View Source
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.

View Source
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.

View Source
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).

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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).

View Source
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.

View Source
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.

View Source
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).

View Source
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).

View Source
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.

View Source
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).

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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).

View Source
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.

View Source
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.

View Source
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).

View Source
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
	},
}
View Source
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.

View Source
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.

View Source
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.

View Source
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

func Delete(appID, key string) error

Delete removes the file under (appID, key). A missing file is not an error.

func InstallOnApps added in v1.0.51

func InstallOnApps(parent *cobra.Command, f *cmdutil.Factory)

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

func List(appID string) ([]string, error)

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

func Read(appID, key string) ([]byte, error)

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 Shortcuts

func Shortcuts() []common.Shortcut

Shortcuts returns all apps domain shortcuts.

func Write added in v1.0.51

func Write(appID, key string, data []byte) error

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.

Directories

Path Synopsis
Package gitcred manages the lifecycle of app Git credentials.
Package gitcred manages the lifecycle of app Git credentials.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL