apps

package
v1.0.52 Latest Latest
Warning

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

Go to latest
Published: Jun 11, 2026 License: MIT Imports: 38 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 Miaoda app access scope configuration",
	Risk:        "read",
	Tips: []string{
		"Example: lark-cli apps +access-scope-get --app-id <app_id>",
	},
	Scopes:    []string{"spark:app:read"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	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 Miaoda 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 a Miaoda app. 响应原样透传服务端契约(字符串 scope 枚举 All/Tenant/Range + 拆分的 users/departments/chats 数组)。

View Source
var AppsAccessScopeSet = common.Shortcut{
	Service:     appsService,
	Command:     "+access-scope-set",
	Description: "Set Miaoda app access scope (specific / public / tenant)",
	Risk:        "write",
	Tips: []string{
		`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope tenant`,
		`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope public --require-login`,
		`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope specific --targets '[{"type":"user","id":"<open_id>"}]'`,
	},
	Scopes:    []string{"spark:app:write"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	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 Miaoda 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 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 Miaoda app",
	Risk:        "write",
	Tips: []string{
		`Example: lark-cli apps +create --name "审批系统" --app-type full_stack`,
		`Example: lark-cli apps +create --name "活动页" --app-type html --description "活动报名"`,
	},
	Scopes:    []string{"spark:app:write"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	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 a Miaoda 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 Miaoda app.

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 --env dev --sync-data --app-id <app_id> --yes",
	},
	Scopes:    []string{"spark:app:write"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{
		{Name: "app-id", Desc: "Miaoda app id", Required: true},
		{Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"},
		{Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		_, err := requireAppID(rctx.Str("app-id"))
		return err
	},
	DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
		appID, _ := requireAppID(rctx.Str("app-id"))
		return common.NewDryRunAPI().
			POST(appDbEnvCreatePath(appID)).
			Desc("Create Miaoda app DB environment").
			Body(buildDBEnvCreateBody(rctx))
	},
	Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
		appID, err := requireAppID(rctx.Str("app-id"))
		if err != nil {
			return err
		}
		data, err := rctx.CallAPITyped("POST", appDbEnvCreatePath(appID), nil, buildDBEnvCreateBody(rctx))
		if err != nil {
			return withAppsHint(err, dbEnvCreateHint)
		}
		rctx.OutFormat(data, nil, func(w io.Writer) {
			renderEnvCreatePretty(w, data)
		})
		return nil
	},
}

AppsDBEnvCreate creates a DB environment for a Miaoda app(拆分单库为 dev/online 多环境)。

调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。

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> --env dev --file ./migration.sql --yes`,
		"Tip: filter fields with --jq, e.g. -q '.data.results[].sql_type'",
	},
	Scopes:    []string{"spark:app:write"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{
		{Name: "app-id", Desc: "Miaoda app id", Required: true},
		{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
			Input: []string{common.Stdin}},
		{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
		{Name: "env", Default: "dev", Enum: []string{"dev", "online"}, Desc: "target db environment (default dev; use --env online for the online environment)"},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		if _, err := requireAppID(rctx.Str("app-id")); err != nil {
			return err
		}
		sql := strings.TrimSpace(rctx.Str("sql"))
		file := strings.TrimSpace(rctx.Str("file"))
		if sql != "" && file != "" {
			return appsValidationError("--sql and --file are mutually exclusive").
				WithParams(
					appsInvalidParam("--sql", "mutually exclusive with --file"),
					appsInvalidParam("--file", "mutually exclusive with --sql"),
				)
		}
		if file != "" {
			data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
			if err != nil {
				return appsValidationParamError("--file", "--file: %v", err).WithCause(err)
			}

			rctx.Cmd.Flags().Set("sql", string(data))
			sql = strings.TrimSpace(string(data))
		}
		if sql == "" {
			return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)").
				WithParams(
					appsInvalidParam("--sql", "one of --sql or --file is required"),
					appsInvalidParam("--file", "one of --sql or --file is required"),
				)
		}
		return nil
	},
	DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
		appID, _ := requireAppID(rctx.Str("app-id"))
		return common.NewDryRunAPI().
			POST(appSQLPath(appID)).
			Desc("Execute SQL on Miaoda app database").
			Params(buildDBSQLParams(rctx)).
			Body(buildDBSQLBody(rctx))
	},
	Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
		appID, err := requireAppID(rctx.Str("app-id"))
		if err != nil {
			return err
		}
		raw, err := rctx.CallAPITyped("POST", appSQLPath(appID),
			buildDBSQLParams(rctx),
			buildDBSQLBody(rctx))
		if err != nil {
			return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table <table>`; for day-to-day debugging target the dev database with `--env dev`")
		}

		stmts := parseSQLResult(common.GetString(raw, "result"))

		data := map[string]interface{}{"results": stmts}

		if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
			if rctx.Format == "pretty" {
				renderSQLPretty(rctx.IO().Out, stmts)
				return output.PartialFailure(output.ExitAPI)
			}
			return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil)
		}

		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 检测到哨兵 后按 partial failure 上报(exit 非 0):stdout 输出 ok:false 数据,带 results / statement_index / error_code / error_message / rolled_back / note,避免 agent 误判 ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit 落地,故 rolled_back=false(真机 boe 实证)。

JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。

Risk: high-risk-write —— SQL 可含 DML/DDL,框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。

SQL 来源二选一:--sql(内联文本,或 - 读 stdin)/ --file(.sql 文件路径,受 CLI 相对路径约束)。 --file 在 Validate 阶段读出内容、归一化到 --sql,下游统一从 rctx.Str("sql") 取。

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: []common.Flag{
		{Name: "app-id", Desc: "Miaoda app id", Required: true},
		{Name: "table", Desc: "table name", Required: true},
		{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		if _, err := requireAppID(rctx.Str("app-id")); err != nil {
			return err
		}
		if strings.TrimSpace(rctx.Str("table")) == "" {
			return 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 Miaoda app db table schema").
			Params(buildDBTableGetParams(rctx))
	},
	Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
		appID, err := requireAppID(rctx.Str("app-id"))
		if err != nil {
			return err
		}
		path := appTablePath(appID, strings.TrimSpace(rctx.Str("table")))
		data, err := rctx.CallAPITyped("GET", path, buildDBTableGetParams(rctx), nil)
		if err != nil {
			return withAppsHint(err, dbTableGetHint)
		}
		rctx.OutFormat(data, nil, func(w io.Writer) {

			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 a Miaoda app database (cursor pagination)",
	Risk:        "read",
	Tips: []string{
		"Example: lark-cli apps +db-table-list --app-id <app_id>",
		"Tip: filter fields with --jq, e.g. -q '.data.items[].name'",
	},
	Scopes:    []string{"spark:app:read"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{
		{Name: "app-id", Desc: "Miaoda app id", Required: true},
		{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
		{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
		{Name: "page-token", Desc: "pagination cursor from previous response"},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		_, err := requireAppID(rctx.Str("app-id"))
		return err
	},
	DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
		appID, _ := requireAppID(rctx.Str("app-id"))
		return common.NewDryRunAPI().
			GET(appTablesPath(appID)).
			Desc("List Miaoda app db tables").
			Params(buildDBTableListParams(rctx))
	},
	Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
		appID, err := requireAppID(rctx.Str("app-id"))
		if err != nil {
			return err
		}
		data, err := rctx.CallAPITyped("GET", appTablesPath(appID), buildDBTableListParams(rctx), nil)
		if err != nil {
			return withAppsHint(err, dbTableListHint)
		}

		items := projectTableListItems(data["items"])
		data["items"] = items
		rctx.OutFormat(data, nil, func(w io.Writer) {
			renderTableListPretty(w, items)
		})
		return nil
	},
}

AppsDBTableList lists tables in a Miaoda app's database.

GET /apps/{app_id}/tables(cursor 分页),response items[] 含 estimated_row_count / size_bytes optional 字段,默认返回,不必额外传 query。

输出裁剪:server 给每张表回完整 columns[](与 +db-table-get 同源、内容一致)。CLI 用白名单 投影(dbTableListItem)只组装产品要求字段、把 columns[] 折算成 column_count,避免逐表重复列定义 放大 token、并与 +db-table-get 职责区分。完整列定义 / 索引 / 约束 / DDL 用 +db-table-get。

pretty 渲染 5 列:name / description / estimated_row_count / size / columns(即 column_count); 列间两空格、列对齐填充、空 description 用 "—" 占位、size 按 KB/MB/GB 友好格式化。

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(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))).
			Desc("Pull app startup env vars into the local .env.local file").
			Set("project_path", projectPath).
			Set("env_file", envFile)
	},
	Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
		appID := strings.TrimSpace(rctx.Str("app-id"))
		_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
		if err != nil {
			return 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
		}

		path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
		data, err := rctx.CallAPITyped("POST", path, nil, nil)
		if err != nil {
			return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
		}

		envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
		if err != nil {
			return err
		}
		if envVars == nil {
			envVars = map[string]string{}
		}
		envVars["FORCE_DB_BRANCH"] = "dev"
		original, err := readEnvPullFile(envFile)
		if err != nil {
			return err
		}
		merged, updated, created := mergeEnvPullFileContent(original, envVars)
		if err := ensureEnvPullParentDir(envFile); err != nil {
			return err
		}
		if err := validate.AtomicWrite(envFile, []byte(merged), 0o600); err != nil {
			return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot write %s: %v", envFile, err)}, Cause: err}
		}

		result := buildEnvPullSuccessData(appID, envFile, databaseInfo)
		rctx.OutFormat(result, nil, func(w io.Writer) {
			writeEnvPullPretty(w, appID, envFile, databaseInfo, skippedKeys)
		})
		_ = updated
		_ = created
		return nil
	},
}

AppsEnvPull pulls startup env vars for an app into the local .env.local file.

View Source
var AppsGitCredentialInit = common.Shortcut{
	Service:     appsService,
	Command:     "+git-credential-init",
	Description: "Initialize Git credentials and a URL-scoped Git helper for a Miaoda app repository",
	Risk:        "write",
	Tips: []string{
		"Example: lark-cli apps +git-credential-init --app-id <app_id>",
	},
	Scopes:    []string{"spark:app:read"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{
		{Name: "app-id", Desc: "Miaoda app ID", Required: true},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		if strings.TrimSpace(rctx.Str("app-id")) == "" {
			return 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 a Miaoda 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 Miaoda Git credential", err)
		}
		payload := map[string]interface{}{
			"app_id":         result.AppID,
			"repository_url": result.GitHTTPURL,
			"status":         initStatus(result),
		}
		if result.ConfigWarning != "" {
			payload["git_config_warning"] = result.ConfigWarning
		}
		rctx.OutFormat(payload, nil, func(w io.Writer) {
			title := "Git credential initialized"
			if result.Refreshed {
				title = "Git credential refreshed"
			}
			fmt.Fprintln(w, title)
			fmt.Fprintln(w)
			fmt.Fprintf(w, "App ID: %s\n", result.AppID)
			fmt.Fprintf(w, "Status: %s\n", initStatus(result))
			fmt.Fprintf(w, "Repository URL: %s\n", result.GitHTTPURL)
			if result.ConfigWarning != "" {
				fmt.Fprintln(w)
				fmt.Fprintln(w, "Git credential saved, but Git helper was not configured")
				fmt.Fprintf(w, "Reason: %s\n", result.ConfigWarning)
				fmt.Fprintf(w, "Next step: lark-cli apps +git-credential-init --app-id %s\n", result.AppID)
				return
			}
			fmt.Fprintln(w)
			fmt.Fprintln(w, "Next step:")
			fmt.Fprintf(w, "  git clone %s\n", result.GitHTTPURL)
		})
		return nil
	},
}
View Source
var AppsGitCredentialList = common.Shortcut{
	Service:     appsService,
	Command:     "+git-credential-list",
	Description: "List local Git credentials for Miaoda app repositories",
	Risk:        "read",
	Tips: []string{
		"Example: lark-cli apps +git-credential-list",
	},
	Scopes:    []string{},
	AuthTypes: []string{"user"},
	HasFormat: true,
	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 Miaoda Git credentials", err)
		}
		payload := map[string]interface{}{
			"count":       len(records),
			"credentials": gitCredentialListPayload(records),
		}
		rctx.OutFormat(payload, nil, func(w io.Writer) {
			if len(records) == 0 {
				fmt.Fprintln(w, "No Git credentials initialized")
				fmt.Fprintln(w)
				fmt.Fprintln(w, "Next step: lark-cli apps +git-credential-init --app-id <app_id>")
				return
			}
			tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
			fmt.Fprintln(tw, "App ID\tRepository URL\tStatus")
			for _, record := range records {
				fmt.Fprintf(tw, "%s\t%s\t%s\n", record.AppID, record.GitHTTPURL, gitCredentialDisplayStatus(record.Status))
			}
			_ = tw.Flush()
			fmt.Fprintln(w)
			fmt.Fprintln(w, "Profile switches do not remove old URL-scoped Git helpers automatically.")
			fmt.Fprintln(w, "Cleanup: lark-cli apps +git-credential-remove --app-id <app_id>")
		})
		return nil
	},
}
View Source
var AppsGitCredentialRemove = common.Shortcut{
	Service:     appsService,
	Command:     "+git-credential-remove",
	Description: "Remove local Git credentials and the URL-scoped Git helper for a Miaoda app repository",
	Risk:        "write",
	Tips: []string{
		"Example: lark-cli apps +git-credential-remove --app-id <app_id>",
	},
	Scopes:    []string{},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{
		{Name: "app-id", Desc: "Miaoda app ID", Required: true},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		if strings.TrimSpace(rctx.Str("app-id")) == "" {
			return 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 Miaoda Git credential", err)
		}
		payload := map[string]interface{}{
			"app_id":  result.AppID,
			"removed": result.Removed,
		}
		if result.ConfigWarning != "" {
			payload["git_config_warning"] = result.ConfigWarning
		}
		rctx.OutFormat(payload, nil, func(w io.Writer) {
			if !result.Removed {
				fmt.Fprintln(w, "No local Git credential found")
				return
			}
			fmt.Fprintln(w, "Git credential removed")
			fmt.Fprintln(w)
			fmt.Fprintf(w, "App ID: %s\n", result.AppID)
			if len(result.Records) > 0 {
				fmt.Fprintf(w, "Repository URL: %s\n", result.Records[0].GitHTTPURL)
			}
			fmt.Fprintln(w, "Status: removed")
			if result.ConfigWarning != "" {
				fmt.Fprintln(w)
				fmt.Fprintln(w, "Git config cleanup warning")
				fmt.Fprintf(w, "Reason: %s\n", result.ConfigWarning)
			}
		})
		return nil
	},
}
View Source
var AppsHTMLPublish = common.Shortcut{
	Service:     appsService,
	Command:     "+html-publish",
	Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
	Risk:        "write",
	Tips: []string{
		"Example: lark-cli apps +html-publish --app-id <app_id> --path ./dist",
		"Example: lark-cli apps +html-publish --app-id <app_id> --path ./site --dry-run",
	},
	Scopes:    []string{"spark:app:write"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{
		{Name: "app-id", Desc: "Miaoda 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())
		}
		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 a Miaoda app's code and local development environment",
	Risk:        "write",
	Tips: []string{
		"Example: lark-cli apps +init --app-id <app_id> --dir <dir>",
		"Example: lark-cli apps +init --app-id <app_id> --dir <dir> --dry-run",
	},

	Scopes:    []string{},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{

		{Name: "app-id", Desc: "Miaoda app ID"},
		{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
		{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		if strings.TrimSpace(rctx.Str("app-id")) == "" {
			return 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 Miaoda app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
			Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
			Set("checkout", "git checkout "+defaultInitBranch).
			Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
			Set("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes").
			Set("template", template).
			Set("env_pull", fmt.Sprintf("apps +env-pull --app-id %s --project-path <clone_path> --format json (after successful init)", appID))
		dir, err := resolveTargetPath(rctx, appID)
		if err != nil {
			dry.Set("dir_error", err.Error())
			dir = defaultCloneDir(appID)
		} else if isAlreadyInitialized(dir) {
			dry.Set("already_initialized", true)
		} else if e := ensureEmptyDir(dir); e != nil {
			dry.Set("dir_error", e.Error())
		}
		dry.Set("clone", fmt.Sprintf("git clone -- <repository_url-from-credential-init> %s", dir))
		dry.Set("clone_path", dir)
		return dry
	},
	Execute: appsInitExecute,
}

AppsInit initializes a Miaoda app's code and local development environment.

View Source
var AppsList = common.Shortcut{
	Service:     appsService,
	Command:     "+list",
	Description: "List Miaoda 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 Miaoda 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 Miaoda 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 AppsReleaseCreate = common.Shortcut{
	Service:     appsService,
	Command:     "+release-create",
	Description: "Create a release for a Miaoda app (returns release_id for status polling)",
	Risk:        "write",
	Tips: []string{
		"Example: lark-cli apps +release-create --app-id <app_id>",
		"Example: lark-cli apps +release-create --app-id <app_id> --branch sprint/default --dry-run",
	},
	Scopes:    []string{"spark:app:write"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{
		{Name: "app-id", Desc: "Miaoda app ID", Required: true},
		{Name: "branch", Desc: "release branch (server uses default if omitted)"},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		if strings.TrimSpace(rctx.Str("app-id")) == "" {
			return 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 a Miaoda 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: "Miaoda app ID", Required: true},
		{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		if strings.TrimSpace(rctx.Str("app-id")) == "" {
			return 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
		}
		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 a Miaoda app's release history (most recent first)",
	Risk:        "read",
	Tips: []string{
		"Example: lark-cli apps +release-list --app-id <app_id>",
		"Tip: filter fields with --jq, e.g. -q '.data.releases[].release_id'",
	},
	Scopes:    []string{"spark:app:read"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{
		{Name: "app-id", Desc: "Miaoda app ID", Required: true},
		{Name: "status", Enum: []string{"publishing", "finished", "failed"}, Desc: "filter by release status: publishing | finished | failed"},
		{Name: "page-size", Type: "int", Default: "20", Desc: "page size (max 500)"},
		{Name: "page-token", Desc: "pagination cursor from a previous response"},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		if strings.TrimSpace(rctx.Str("app-id")) == "" {
			return 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 a Miaoda app's release history (most recent first).

View Source
var AppsSessionCreate = common.Shortcut{
	Service:     appsService,
	Command:     "+session-create",
	Description: "Create a session under a Miaoda app",
	Risk:        "write",
	Tips: []string{
		"Example: lark-cli apps +session-create --app-id <app_id>",
	},
	Scopes:    []string{"spark:app:write"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{
		{Name: "app-id", Desc: "app ID", Required: true},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		if strings.TrimSpace(rctx.Str("app-id")) == "" {
			return 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 a Miaoda app")
	},
	Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
		data, err := rctx.CallAPITyped("POST", sessionsPath(rctx.Str("app-id")), nil, nil)
		if err != nil {
			return withAppsHint(err, appIDListHint)
		}
		rctx.OutFormat(data, nil, func(w io.Writer) {
			fmt.Fprintf(w, "session created: %s\n", common.GetString(data, "session_id"))
		})
		return nil
	},
}

AppsSessionCreate creates a new session under an existing Miaoda 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 a Miaoda app (cursor pagination)",
	Risk:        "read",
	Tips: []string{
		"Example: lark-cli apps +session-list --app-id <app_id>",
		"Tip: filter fields with --jq, e.g. -q '.data.sessions[].session_id'",
	},
	Scopes:    []string{"spark:app:read"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{
		{Name: "app-id", Desc: "app ID", Required: true},
		{Name: "page-size", Type: "int", Default: "20", Desc: "page size (max 50)"},
		{Name: "page-token", Desc: "pagination cursor from previous response"},
	},
	Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
		if strings.TrimSpace(rctx.Str("app-id")) == "" {
			return 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 a Miaoda app").
			Params(buildSessionListParams(rctx))
	},
	Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
		data, err := rctx.CallAPITyped("GET", sessionsPath(rctx.Str("app-id")), buildSessionListParams(rctx), nil)
		if err != nil {
			return withAppsHint(err, appIDListHint)
		}
		sessions, _ := data["sessions"].([]interface{})
		rctx.OutFormat(data, nil, func(w io.Writer) {
			rows := make([]map[string]interface{}, 0, len(sessions))
			for _, item := range sessions {
				m, ok := item.(map[string]interface{})
				if !ok {
					continue
				}
				rows = append(rows, map[string]interface{}{
					"session_id": m["session_id"],
					"name":       m["name"],
					"is_active":  m["is_active"],
					"updated_at": m["updated_at"],
				})
			}
			output.PrintTable(w, rows)
		})
		return nil
	},
}

AppsSessionList lists sessions under a Miaoda app (cursor pagination, single page).

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 AppsUpdate = common.Shortcut{
	Service:     appsService,
	Command:     "+update",
	Description: "Partially update a Miaoda app (only provided fields are sent)",
	Risk:        "write",
	Tips: []string{
		`Example: lark-cli apps +update --app-id <app_id> --name "新名称"`,
		`Example: lark-cli apps +update --app-id <app_id> --description "..."`,
	},
	Scopes:    []string{"spark:app:write"},
	AuthTypes: []string{"user"},
	HasFormat: true,
	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 a Miaoda 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 a Miaoda 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

Jump to

Keyboard shortcuts

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