mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat: apps support multi dev modes (#1175)
* feat: add fullstack app-type and --message to apps +create (#1) * feat: accept fullstack app-type and require --message for it * feat: inject message into fullstack create request body * refactor: align fullstack message injection with existing body-build style * docs: document fullstack app-type and --message for apps +create * docs: keep scene numbering consistent in lark-apps-create reference * docs: add HTML/fullstack intent routing to lark-apps SKILL.md * docs: cover fullstack in lark-apps skill description and clarify HTML flow step * test: assert fullstack in allow-list error and reject wrong-cased fullstack * feat: drop --message from apps +create (#4) * feat: drop --message from apps +create * docs: drop --message and document agent-generated name/description for apps +create * feat: add apps local key-value file storage (#5) * feat: add Miaoda app git credential support (#9) * fix: remove APIError detail field dependency * docs(apps): expand lark-apps skill for local-dev & cloud-chat workflows (#3) Reframe lark-apps from an HTML-publish skill into a full Miaoda app dev tool covering three paths: local fullstack dev, HTML hosting, and cloud session dev. Builds on the fullstack create change already on this branch. - SKILL.md: 3-path routing table; mental models (code via native git, develop/main branch model, DB via +db-* through Miaoda, env auto-pulled by `npm dev run`, auto-managed credentials); command index for the new verbs; ambiguous-input fallback (infer app type from need, ask local vs cloud instead of assuming; default HTML when no signal) - add local-dev and cloud-dev playbooks - create: keep HTML/fullstack + required --message; add local/cloud scene routing and --enable-multi-env-db - list: usable by agents with --filter; app_id resolution order (user-provided / .spark/meta.json / +list --filter) Co-authored-by: wangjiangwen-gif <286006750+wangjiangwen-gif@users.noreply.github.com> Co-authored-by: raistlin042 <lvxinsheng@bytedance.com> * feat(apps): add 4 db CLI commands (table-list / table-schema / sql / dev-init) 妙搭 data CLI 4 条命令,复用存量 OpenAPI URL + 1 个新增 dev-init: - +db-table-list → GET /apps/{id}/tables(游标分页,AppTable 含预估行数/占用空间) - +db-table-schema → GET /apps/{id}/tables/{name}(默认结构化 schema;--format pretty 出建表 DDL) - +db-sql → POST /apps/{id}/sql_commands(?transactional=false DBA 模式) - +db-dev-init → POST /apps/{id}/db_dev_init(单库→online/dev,不可逆,high-risk-write) 要点: - sql result 兼容两种 wire 形态(结构化 [{sql_type,data,record_count}] 与 legacy ["rows-json"]) - 多语句失败:server 返 code:0 + ERROR 哨兵,CLI 升级成 typed api_error(exit 非 0), detail 带 statement_index/completed/rolled_back,防止 agent 误判 ok:true 假成功 - pretty 渲染对齐 miaoda:列间两空格、CJK 双宽、size 友好格式(KB/MB/GB) - 单测 + e2e dry-run 全覆盖;BOE 真机 e2e 验证通过(25 PASS) - SKILL.md 注册 4 条命令 + 4 篇 reference 注:内含的 BOE 联调专用 env 覆盖(LARK_CLI_OPEN_API_BASE / LARK_CLI_X_TT_ENV, internal/cmdutil + internal/envvars)未包含在本次提交,仅本地联调用。 Change-Id: I0fe4458086708a93941e2dee852fa6a10b53bd4a * docs(lark-apps): db 能力补进 SKILL.md description 的 WHEN 段 按 skill 质量规范(description 三段式 WHAT+WHEN+NOT,加载前唯一可见信息), 原 WHEN 仅"连数据库调试"含糊覆盖 db。补成「查看或操作应用数据库(看表结构 / 跑 SQL / 初始化 dev 环境)」,让 +db-table-schema / +db-sql / +db-dev-init 类查询能精确触发,净增 ~12 字无膨胀。 Change-Id: Id52819fa7d6b8ed0c1f174bf5946d55da7b893d7 * Feat/apps env pull (#11) * feat: add apps env-pull shortcut * fix: support array env_vars response in apps env-pull * fix(apps): improve env-pull merge and expiry output * feat: add keyword/scope/app-type query to apps +list and unhide it (#8) * feat: switch apps +create --app-type enum to lowercase html/full_stack * feat: add keyword/scope/app-type query to apps +list and unhide it * docs: document apps +list query params and lowercase app_type enum * test: update apps cli_e2e dry-run tests for lowercase app_type and +list filters * docs: trim redundant app_type case-sensitivity note in create skill * docs: single-source apps +list usage contract to SKILL.md * feat: add apps publish shortcuts (publish/status/history/error-log) (#12) * feat: add apps publish shared guard and NodeStatus mapping * test: cover json.Number path in injectStatusName * feat: add apps +publish shortcut Implements the `apps +publish` command with dry-run preview (upstream PSM path shown) and an Execute gated by ensurePublishWired() per the not-yet-deployed OpenAPI gateway constraint (publishAPIWired=false). * refactor: make apps publish path placeholders var to satisfy go vet Declare the four publishXxxPath constants as var instead of const so go vet's printf analyzer skips them while they are empty placeholders. Revert the Execute path-build in apps_publish.go from strings.Replace back to fmt.Sprintf (now safe because the format string is a var). * feat: add apps +publish-history shortcut * feat: add apps +publish-status shortcut * feat: add apps +publish-error-log shortcut * feat: register apps publish shortcuts Add AppsPublish, AppsPublishHistory, AppsPublishStatus, AppsPublishErrorLog to Shortcuts() and update count test from 6 → 10. * docs: add skill references for apps publish shortcuts * docs: surface apps publish shortcuts in lark-apps SKILL.md * docs: clarify publish instance id is not an approval instance * docs: nudge agent to run apps +publish --dry-run for release requests * feat: update apps publish shortcuts to v1.0.381 release protocol Rename concept instance→release across all 4 publish shortcuts and their tests: NodeStatus→ReleaseStatus enum, --instance-id→--release-id flag, pipelineTaskID→releaseID response field, errorJobs→errorLogs, and upstream HTTP path consts→RPC method name consts (PSM lark.apaas.devops v1.0.381). Dry-run now shows psm+rpc_method instead of an HTTP path. * docs: update apps publish skill docs to v1.0.381 release protocol * fix: soften apps publish unavailable hint to user-facing language * feat: update apps publish to v1.0.385 string status + --status filter - Remove obsolete int-enum machinery (releaseStatusName/toInt/injectStatusName) and their encoding/json + fmt imports from apps_publish_common.go - +publish Execute now returns status string alongside release_id - +publish-history gains --status Enum flag (publishing/finished/failed); buildHistoryBody gains status param, table column status_name→status - +publish-status Execute drops injectStatusName, pretty prints out["status"] - +publish-error-log shapeErrorLog is string passthrough (no status_name) - Unit tests updated: delete 3 obsolete common tests, update history/error-log * docs: update apps publish docs to v1.0.385 string status + --status filter * feat: wire apps publish shortcuts to final gateway paths (guard stays until deploy) Replace RPC-name placeholders with real OpenAPI paths (publishCreate/Get/ErrorLog/ListPath consts). Switch DryRun to idiomatic HTTP form (POST/GET + real URL + body/params). Fix body/query placement: publish body has no app_id (path-only); history switches from POST body to GET query with snake page_token. Fix Execute response reads to snake_case fields (release_id, created_at, updated_at, error_logs). publishAPIWired stays false; 1-line flip activates live calls. * docs: update apps publish docs to final gateway paths Replace RPC/PSM dry-run example with real HTTP form (POST/GET /open-apis/spark/v1/apps/:app_id/releases[/:release_id[/error_logs]]). Fix all response field names to snake_case (release_id, created_at, updated_at, error_log). Note --status/--limit/--page-token as HTTP query params in publish-history. * feat: enable apps publish gateway calls (remove not-deployed guard) * docs: remove not-deployed transition notes from apps publish docs * feat: use spark:app:publish scope for apps +publish * feat(apps): add +init shortcut to initialize Miaoda app repo (#6) * feat(apps): add command runner and credential redaction for +init * fix(apps): make credential redaction scheme matching case-insensitive * feat(apps): add +init shortcut declaration, validation, and dry-run * feat(apps): implement +init orchestration (credential-init, clone, checkout, conditional push) * fix(apps): redact full userinfo when repo URL contains literal @ * docs(apps): add +init skill reference * fix(apps): declare explicit empty Scopes on +init shortcut * fix(apps): consume repository_url from +git-credential-init in +init * feat(apps): add +init template flag and absolute-path dir resolution * refactor(apps): use shared charcheck for +init --dir validation * feat(apps): add meta.json, steering, and empty-repo helpers for +init * feat(apps): add +init npx scaffold orchestration (init/upgrade branches) * feat(apps): wire +init scaffold, already-initialized short-circuit, npx dep check * docs(apps): document +init npx scaffold, --template, --dir, already-initialized * docs(apps): correct stale +git-credential-init unreleased note in +init ref * fix(apps): reject all control chars in +init --dir * feat(apps): add +init progress logging and optional --template resolver * refactor(apps): inline constant in +init scaffold progress log * docs(apps): document +init optional --template and stderr progress contract * feat(apps): treat README-only repo as empty and commit with --no-verify in +init * docs(apps): explain README-seed match and --no-verify rationale in +init * docs(apps): document README-seed empty detection and commit --no-verify * feat(apps): add session conversation lifecycle shortcuts (#13) * feat(apps): add +session-create shortcut * fix(apps): remove unused sessionPath helper, assert empty +session-create body * feat(apps): add +session-list shortcut * feat(apps): add +session-read shortcut * feat(apps): add +session-stop shortcut * feat(apps): add +chat shortcut * feat(apps): register session lifecycle shortcuts * docs(apps): add session conversation skill reference * docs(apps): clarify fullstack session_id source and fallback * style(apps): gofmt apps_session_create.go * docs(apps): add conversation/session triggers to skill routing description * docs(apps): add conversation flow guidance (when to reuse vs new session, per-step user prompts) * docs(apps): slim session reference per skill quality standard (4047->1726 tok) * docs(apps): tighten session additions in SKILL.md (4394->4145 tok) * fix(apps): align +chat with v7.8 contract (async, no turn_id in response) * fix(apps): update +chat path to .../sessions/{id}/chat (backend endpoint change) * docs(apps): align SKILL.md session command shape with v7.8 contract * style(apps): gofmt apps_db_table_schema_dryrun_test.go Go 1.19+ gofmt 文档注释列表缩进新规则(普通缩进 → tab 对齐), 修复 fast-gate CI 的 gofmt 卡点。 Change-Id: Ic246a659e016d9d6216182199ef300ae6f00ef9d * feat(apps): split +init commit, plainer wording, align skill branches (#14) * refactor(apps): plainer +init progress/help wording, keep scaffold key * refactor(apps): add porcelain change classifier for +init commit split * feat(apps): split +init empty-repo commit into code + config, reword subjects * refactor(apps): scaffold-kind constants and pathspec assertions for +init split * docs(apps): use +init in Path A; align app-repo branch to sprint/default * docs(apps): align local-dev playbook to sprint/default + origin remote * docs(apps): document +init two-commit split and plainer init wording * docs(apps): require asking clone dir before +init, no assumed path * fix(apps): stage +init commits by exact paths to avoid gitignore error * refactor(apps): lowercase miaoda in +init commit subjects * test(apps): cover +init upgrade path with real git * fix: harden app git credential handling (#16) * fix: harden git credential refresh fallback (#18) * fix(apps): validate env-pull key names before writing to .env.local (#17) * fix(apps): validate env-pull key names before writing to .env.local S2 (medium-low) from security review: env-pull wrote server-returned env KEYs to .env.local without validation. A compromised or MITM'd backend could inject arbitrary lines via keys containing newlines. - Add envKeyPattern regex to validate keys match [A-Za-z_][A-Za-z0-9_]* - extractEnvPullVars now returns skippedKeys for invalid key names - Invalid keys are skipped (not hard-fail) so remaining valid keys are still pulled - writeEnvPullPretty prints a warning listing skipped keys * fix(skills): correct npm script syntax from 'npm dev run' to 'npm run dev' * fix(skills): align env-pull guidance with implementation 🤖 Generated with [Aiden x Claude Code] * test(apps): cover storage/git-credential error paths and fix tz-flaky env-pull tests (#19) The coverage and unit-test CI jobs failed on two timezone-dependent assertions in apps_env_pull_test.go: the code renders the database expiry via time.Local() while the tests hard-coded a CST literal, so they failed under CI's UTC. Compute the expected string from the same timestamp with Local() instead, making the assertions timezone-agnostic. Also add unit tests for the error branches codecov flagged as uncovered, taking storage.go and git_credential.go to 100%: - storage Read/Write/Delete/List filesystem-error paths - +git-credential-remove ConfigWarning output (pretty and JSON) - gitCredentialLocalError nil passthrough * fix(apps): silence +init forbidigo, npx app sync -y --prefer-online (#20) * fix(apps): add Subtype to env-pull error literals (#21) typed_error_completeness lint requires all errs.XxxError literals to set Problem.Subtype. Add the missing field to 11 error constructions: - ValidationError (user input checks): SubtypeInvalidArgument - ValidationError (API response parsing): SubtypeInvalidResponse - InternalError (filesystem ops): SubtypeUnknown * feat(apps): inject FORCE_DB_BRANCH=dev in env-pull output (#23) * feat(apps): inject FORCE_DB_BRANCH=dev in env-pull output Always write FORCE_DB_BRANCH="dev" into the resolved .env.local after extracting upstream env_vars, so downstream tooling pinning the dev database branch does not need a separate manual edit. Existing local values are overwritten in place via the canonical merge path. * docs(skills): document apps +env-pull in lark-apps skill Add the env-pull entry to the lark-apps SKILL index and ship the matching reference doc covering args, merge semantics, return shape, error envelope subtypes, and dry-run behavior so AI agents can route to it without reading the Go source. * feat(apps): surface is_published and online_url in +list pretty view (#22) * docs: refactor lark-apps skill per quality spec (#24) Slim SKILL.md and references against the lark-cli skill quality spec while preserving domain knowledge and safety guardrails. - Compress SKILL.md (drop the MUST-read prelude, full command-index tables, and content already owned by lark-shared: auth, scope, exit-10, risk policy, _notice); add version field; zero CRITICAL markers. - Defer flag enumeration in references to `--help`; convert narration-inducing prohibitions into positive defaults; de-duplicate the per-file error.hint relay into a single resident SKILL.md rule. - Fix stale facts found against shortcuts/apps source: drop the non-existent +create --message and --enable-multi-env-db flags, +list --filter (now --keyword), +db-multi-env-init (now +db-dev-init), and the removed html-publish cwd hard-reject. - Keep all safety guardrails: db-dev-init irreversibility/exit-10, db-sql non-transactional multi-statement, git-credential token handling, html-publish credential scan, access-scope confirmation. - Restore intent lost during slimming: release_id is not an approval instance (do not route to lark-approval); resolve access-scope targets via contact/im; ask the user before publishing as a side-effect; distinguish developing an existing app locally (+init) from creating a new one (+create). * test(apps): supplement shortcuts/apps unit-test coverage to 88% (#25) * test(apps): cover db-table-list numeric/byte formatting helpers * test(apps): cover db-sql cell/code/dml/error render helpers * test(apps): cover env-pull newline/expiry/extract-vars helpers * test(apps): cover db-sql render branches and env-pull expiry edge case * test(apps): cover init empty-dir/meta/ls-files error branches * test(apps): cover env-pull target/read/parent-dir error branches * test(apps): cover stage-and-commit and commit-push error branches * test(apps): cover access-scope target split and JSON validation * test(apps): cover html-publish decode error and scaffold sync failure * test(apps): cover apps-update body field combinations * test(apps): cover access-scope body build branches * feat(apps): pass --local to npx skills sync in +init (#26) * feat(apps): pass --local to all npx miaoda-cli calls in +init * feat(apps): pass --local only to npx skills sync in +init * docs(apps): surface +publish and +init dir-choice in local-dev flow (#27) * docs(apps): surface +publish as deploy action in skill routing * docs(apps): add explicit deploy-after-local-edit section to local-dev * docs(apps): promote +init dir-choice instruction to a domain rule * docs(apps): make dev-method a signal-driven entry gate before routing (#28) * docs(apps): restore three-path overview line in apps skill intro (#29) * feat(apps): add executable Examples to shortcut --help and error hints (#30) * test(apps): guard every shortcut has a help Example and no PII * feat(apps): add help Examples to all 24 apps shortcuts * feat(apps): add actionable hints to high-impact error paths * test(apps): cover withAppsHint set-if-empty hint behavior * feat(apps): use concrete enum value in access-scope-set Example * docs(apps): clarify db-sql/db-table-list json default output behavior 两处仅补充注释,不改逻辑: - +db-sql: data.results 在 json 默认路径原样透出全部行,CLI 不二次截断; server 对单条 SELECT 有 1000 行硬上限、超出直接返报错,非无界 token 黑洞。 - +db-table-list: json 默认透出含每表完整 columns[] 系产品设计(list 接口本就 返回列定义,json 消费方一次拿全量、免逐表再调 +db-table-schema),pretty 仅摘计数。 Change-Id: I1a49de8defc4428bfe1e774e4fd7adb45e59e3af * feat(apps): command-layer AI-friendliness governance (P0+P1) (#32) * fix(apps): normalize --app-type case to align with server * refactor(apps): migrate CallAPI to CallAPITyped for typed errors and retryable * feat(apps): trim icon_url and created_at from +list default output * feat(apps): add actionable hints to high-impact error paths * feat(apps): add 2-3 help Examples to +chat and +access-scope-set * docs(apps): add --jq filter tips to list/db commands * docs(apps): sync +list reference with trimmed output fields * test(apps): assert error hints and messages carry no secrets or PII * fix(apps): prefix --jq tips with .data. so they run against the response envelope * test(apps): expect --app-type uppercase normalization in create dry-run E2E (#33) * fix(apps): scaffold via @latest miaoda-cli instead of @alpha (#34) * feat(apps): rework lark-apps triggering, routing & confirm policy (#35) * feat(apps): results-oriented triggering, pre-auth floors, terminal URL Widen description WHEN to cover app-building openers (CRM/审批/HTML page) with no Miaoda signal word, WHAT still anchored to 妙搭应用开发与托管. Add a pre-authorization rule (auth words skip confirm) with two non-exempt floors: destructive DDL (DROP/TRUNCATE/ALTER drop|modify column) dry-run, and first public-URL publish (+publish/+html-publish) when no auth word. Exempt html app_type from the local-vs-cloud dev-method gate, and scope that gate to new-app creation only (existing-app ops route directly). Require an accessible URL as the end-to-end terminal step. * feat(apps): apply eval-fix behavior contracts across reference docs init/local-dev: end-to-end default-directory escape hatch; end-to-end new-build starts with +create. db-sql: additive DDL direct-exec when authorized, destructive DDL stays dry-run. local-dev/publish-status: return online_url via +list as the full_stack publish terminal step. cloud-dev: generation != shareable URL, +publish handoff, background until-poll snippet (sleep N && cmd intercepted; deprecate ScheduleWakeup), multi-turn publish precondition. publish/publish-error-log: transient failure (EAI_AGAIN/ETIMEDOUT/registry) discrimination, retry cap 2, honest receipt. env-pull: first-launch fallback. local-dev/db-dev-init: new full_stack ships dual DB, skip +db-dev-init. * refactor(apps): apply review feedback — semantic criteria, drop overfit/unverified content Per line-by-line review of the eval-fix changes: - Entry routing reframed to objective/semantic criteria (new-vs-existing = 'can an existing app be identified'; dev-method = who-writes-code preference), replacing keyword/example matching. - db-sql DDL gate restated by effect (data-loss / reversibility), not a keyword list. - Pre-authorization judged by expressed intent (not a word list); single non-exempt floor (destructive/irreversible DB dry-run); confirm policy in its own section, error.hint in 'failure handling'. - init.md slimmed to command facts (directory choice owned by local-dev, no init<->local-dev cycle); local-dev defers new-vs-existing to the entry. - Reverted unverified/redundant/runtime-coupled additions: cloud-dev session-read preview-URL claim + background-poll snippet + queued_count precondition; publish transient-retry/ScheduleWakeup; env-pull first-launch; db-dev-init positive restatement; SKILL terminal-URL mandate. - Fixed dangling section references after the rename. * fix(apps): scope pre-authorization to hands-off intent, not 'wants a result' (#36) Follow-up to #35. The merged pre-authorization rule treated 'wanting the final result' as authorization, so '先在本地跑起来让我看看' was read as pre-authorized and the agent silently picked a clone directory without asking. Re-state the criterion as the user's hands-off intent (explicit waiver, or an end-to-end directive), judged uniformly across the flow (directory/clone, publish) — not a per-decision carve-out. Merely wanting a result or asking to review is not authorization. * docs: clarify apps cloud dev publish state * fix(apps): require commit+push before publish, clarify deploy flow (#38) * fix(apps): require committing changes before publish in local-dev flow * fix(apps): make commit+push mandatory before publish in agent rules * fix(apps): scope selective-add caveat to incremental deploy, not new-app flow * fix(apps): make pre-publish commit conditional on local changes * fix(apps): tighten pre-publish commit wording in agent rules * fix(apps): cloud-dev does not auto-deploy, add explicit publish step * docs(apps): document +chat init vs incremental turn cost (#39) First +chat on a not-initialized app runs full design+gen server-side (~20-50 min); chat on an already-initialized app is incremental and finishes in minutes. Surface this in the +chat Go comment as a pointer and put the init-state check + matching polling cadence (5-10s vs 60-120s) in the lark-apps cloud-dev skill reference as the canonical source. Cloud-side init check uses +session-read committed-version info or +list is_published:true. * docs(apps): document +chat init vs incremental turn cost (#40) First +chat on a not-initialized app runs full design+gen server-side (~20-50 min); chat on an already-initialized app is incremental and finishes in minutes. Surface this in the +chat Go comment as a pointer and put the init-state check + matching polling cadence (5-10s vs 60-120s) in the lark-apps cloud-dev skill reference as the canonical source. Cloud-side init check uses +session-read committed-version info or +list is_published:true. * feat(apps): surface online_url/error_logs in +publish-status output (#41) * refactor(apps): extract shared release error-log table helper * fix(apps): keep error-log table byte-identical for null error_logs * feat(apps): surface online_url/error_logs in +publish-status output * docs(apps): read online_url/error_logs from +publish-status in publish flow * docs(apps): align local/cloud dev publish flow with +publish-status fields * refactor(apps): rename +db-dev-init→+db-env-create, trim db-table-list columns - +db-env-create(原 +db-dev-init):新增 --env 参数(调用方传入,目前只支持 dev), --sync-data 改为 true/false 取值;服务端 URL 仍走 db_dev_init。 - +db-table-list:json 默认用白名单投影(dbTableListItem)只输出产品要求字段, 每表 columns[] 折算成 column_count、不再透出完整列定义(与 +db-table-schema 重复且放大 token);要完整列定义/索引/约束用 +db-table-schema。 - 同步对齐 db 相关 skill 文档(命令名、column_count、env-create 参数)。 - 单测 + cli_e2e dry-run 全绿。 Change-Id: I116ab11807679f8f06ed18221f705bab426d015c * refactor(apps): rename +db-table-schema → +db-table-get 动词对齐 +db-table-list(list/get)。仅命令名 + 标识符 + 文档改名,行为/输出/URL 不变: - AppsDBTableSchema→AppsDBTableGet,文件/测试/cli_e2e test 重命名 - buildDBTableSchemaParams→buildDBTableGetParams - +db-sql / +db-table-list 里的交叉引用 hint、skill 文档同步 Change-Id: I36dfb8fd0d2613492a57dc7815bc58414c145480 * feat: auto-pull env vars after apps +init (#42) * test: route apps +env-pull to its own fake-runner key * feat(apps): add +env-pull envelope parsers for +init * feat(apps): add pullEnv helper invoking sibling +env-pull * feat(apps): +init auto-runs +env-pull after push (non-fatal) * docs(apps): clarify db-sql --query @path is relative-only, use stdin for absolute paths @path 受 lark-cli 全局文件安全策略约束,只接受 cwd 内相对路径;绝对路径 / cwd 不固定 场景改用 stdin(--query - < /abs/file.sql),无需先 cd。 Change-Id: Ib3453810cfc9303d72b4facf3493ad9688eeffd3 * docs(apps): refine db-sql --query path guidance wording 以 agent 视角重写:@ 仅接受工作目录内相对路径,绝对路径/越界路径被拒(CLI 文件访问统一约束); 工作目录外的文件经 stdin 传入。 Change-Id: Ic7db00934b3571368eb704451f4ce1776463806d * feat(apps): make +db-sql high-risk-write (require --yes) +db-sql 可含 DML/DDL,统一升级为 high-risk-write:框架对所有执行强制 --yes 确认关卡 (--dry-run 预览豁免),无 --yes 返 confirmation_required / exit 10。 - Risk: write → high-risk-write(去掉自定义门禁,直接用框架机制) - skill 文档:命令骨架标注 --yes 要求;Agent 规则改为「执行需 --yes,只读可直接带、 破坏性先 dry-run 确认再带」 - 单测所有执行调用补 --yes Change-Id: I57e78832b35fa170a485774e6fb7289109d678c3 * docs(apps): clarify app_ (Miaoda) vs cli_ (Feishu) app id (#46) * 优化云端开发skill,明确执行模型,参数解释 (#44) Co-authored-by: fushengdong.1 <fushengdong.1@bytedance.com> * refactor: rename apps publish commands to release and session-get (#45) * refactor(apps): drop +publish-error-log, rename release path constants * refactor(apps): rename +publish to +release-create * refactor(apps): rename +publish-history to +release-list, unify pagination to --page-size * refactor(apps): rename +publish-status to +release-get Renames apps +publish-status → +release-get (AppsPublishStatus → AppsReleaseGet), updates --release-id desc to reference +release-create, and fixes the Execute error hint to point at +release-list instead of +publish-history. * refactor(apps): rename +session-read to +session-get * docs(apps): rename publish references to release, +session-read to +session-get * refactor(apps): clean up residual publish/session-read references Fix six leftover references missed in Tasks 1-6: +publish-history in jq-tip test wantCmds map and common_test hint fixture (×3), +session-read in apps_chat.go comment+output string (×2), apps_session_stop.go flag desc (×1), apps_chat_test.go comment (×1), and +publish-status in lark-apps-list.md agent rule prose (×1). * docs(apps): clarify release-get link contract and session-get vs session-list * docs(apps): generalize release-list page-size rule to N records * feat(apps): rename +list --scope flag to --ownership (#47) * feat(apps): rename +list --scope flag to --ownership * test(apps): update +list cli_e2e dry-run for --ownership rename * docs(apps): document +list --ownership flag * feat(apps): align +release commands with new release API format (#48) * feat(apps): align +release-create scope to spark:app:write * feat(apps): raise +release-list --page-size documented max to 500 * feat(apps): show commit_id in +release-get pretty output * docs(apps): update release reference docs for page-size 500 and commit_id * test(apps): cover empty commit_id in +release-get pretty output * docs: align lark apps cloud dev release flow * feat(apps): redesign +db-sql → +db-execute (--sql/--file, default env dev) 按 db 子域命令最终设计重做执行入口: - 命令 +db-sql → +db-execute(动词收尾,对齐 +db-table-list/-get) - --query 拆为 --sql(内联/stdin)与 --file(.sql 文件路径),二选一互斥; --file 在 Validate 阶段读出归一化到 --sql - 默认 --env online → dev(打生产库需显式 --env online) - 文件/标识符/注册/测试/cli_e2e/skill 文档全部对齐重命名 - 新增测试:--sql/--file 互斥、--file 读取、默认 env=dev 不在本次范围:--transaction/--no-transaction(服务端 transactional 实为路径切换、 非真事务,需 dataloom 侧先支持真事务开关)、--max-rows/--timeout 等后续项。 Change-Id: I50c06faf83527471446e2a6651ccb51f6eedd6ff * docs(apps): clearer --env online wording for +db-execute 把口语化的「打生产库需显式」改为「需要操作线上环境数据库时,显式指定 --env online」; flag desc 同步去掉 hit production 措辞。 Change-Id: Iee82fccf17e08bddb4b760c3970a416746b10c4c * docs(apps): drop 'ad-hoc' jargon from +db-execute description 中文文档/英文 description 去掉术语 ad-hoc;SELECT/DML/DDL 已表意,含义不丢。 Change-Id: Ie2cccc5fc3491fe5f57190a87b93ecd70405b156 * docs(apps): trim +db-execute when-to-use and --file path wording - 何时用去掉「(查询 / 临时数据修复 / 应急 DDL)」枚举 - --file 路径说明去掉 .. /符号链接/统一约束 的技术化描述,改为「相对路径, 否则用 --sql - < 文件路径」的产品化口吻 Change-Id: Ie70e57895c78650230b6942b03d90a2d95c937f2 * docs(apps): note --file rejects absolute/cwd-escaping paths 简短补回 --file 的路径约束(绝对路径 / 经 ..、符号链接越界会被拒),去掉冗余评注。 Change-Id: I549893c82cafbe97529e08dcbc3ee5496927da18 * fix(apps): replace t.Chdir with os.Chdir in db-execute test (Go 1.23 compat) t.Chdir 是 Go 1.24 API,但 go.mod 为 go 1.23.0,CI(Go 1.23)报 "t.Chdir undefined"。改用 os.Chdir + t.Cleanup 还原,1.23 兼容。 Change-Id: I550611773e5088275be1c4344d4f8269610ce74a * feat(apps): refine +init description and refresh env on re-init * fix(apps): treat accessible-link requests as publish intent (#53) * refactor(apps): +db-env-create --sync-data string-enum → Type:bool 原实现用 string + Enum["true","false"] + == "true" 模拟 bool,啰嗦且非惯用。 改为 Type:bool(rctx.Bool):传 --sync-data 即开启、省略为 false。 同步更新测试、cli_e2e dry-run、skill 文档。 Change-Id: I3068e0577fa20a7cbaf414ca9af3d197f6ae8049 * fix(apps): declare --app-type as strict lowercase enum (#55) * docs(apps): front-load routing, dedupe, and trim lark-apps skill (#56) * docs(apps): front-load intent-routing table and dedupe skill body * docs(apps): dedupe publish guardrail and polling rules in cloud-dev * docs(apps): trim env-pull implementation detail to behavior contract * docs(apps): add +env-pull routing entry in SKILL.md * docs(apps): fix create.md cross-ref to actual SKILL.md section name * feat(apps): add error.hint to command failures and a consistency gate (#57) * feat(apps): add appIDListHint const and wrap 4 pure app-id command failure paths Adds shared `appIDListHint` recovery hint to common.go and wraps the CallAPITyped failure branch of session-create, session-list, update, and release-list to surface an actionable next-step hint on 4xx errors. Includes httpmock unit tests in apps_hints_more_test.go (TDD: red→green). * feat(apps): add sessionStopHint and createHint for session-stop and create commands Adds per-command recovery hints with specific guidance: sessionStopHint points at +session-list and +session-get; createHint explains valid --app-type values and permission failure. Wraps the CallAPITyped failure branch in both commands. * feat(apps): add recovery hints for db-env-create, db-table-get, db-table-list Adds dbEnvCreateHint, dbTableGetHint, and dbTableListHint with actionable cross-command guidance (e.g. pointing at +db-table-list for env conflicts, +db-env-create for missing dev env). Wraps only the CallAPITyped failure branch; requireAppID validation errors are left untouched. * refactor(apps): make session-stop hint runnable and align hint test names * test(apps): guard withAppsHint upstream-wins contract and new hint leak safety * test(apps): add help-skill command consistency gate --------- Co-authored-by: linchao5102 <linchao.5102@bytedance.com> Co-authored-by: Wang <wangjiangwen@bytedance.com> Co-authored-by: wangjiangwen-gif <286006750+wangjiangwen-gif@users.noreply.github.com> Co-authored-by: 陈兴炀 <chenxingyang.1019@bytedance.com> Co-authored-by: aihao-git <aihao.0331@bytedance.com> Co-authored-by: bali <bali@bytedance.com> Co-authored-by: hunnnnngry <chenxi.xichen@bytedance.com> Co-authored-by: shengdongyc <1135978761fsd@gmail.com> Co-authored-by: fushengdong.1 <fushengdong.1@bytedance.com>
This commit is contained in:
@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
||||
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
|
||||
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
||||
| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -21,9 +21,12 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
Command: "+access-scope-get",
|
||||
Description: "Get Miaoda app access scope configuration",
|
||||
Risk: "read",
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
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},
|
||||
},
|
||||
@@ -42,9 +45,9 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPI("GET", path, nil, nil)
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||
}
|
||||
// 原样透传 — 保留服务端字符串枚举 (All/Tenant/Range),不合并 users/departments/chats。
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
|
||||
@@ -27,9 +27,14 @@ var AppsAccessScopeSet = common.Shortcut{
|
||||
Command: "+access-scope-set",
|
||||
Description: "Set Miaoda app access scope (specific / public / tenant)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
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"}},
|
||||
@@ -64,9 +69,9 @@ var AppsAccessScopeSet = common.Shortcut{
|
||||
}
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPI("PUT", path, nil, body)
|
||||
data, err := rctx.CallAPITyped("PUT", path, nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
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"))
|
||||
|
||||
@@ -8,9 +8,62 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func testRuntimeAccessScope(t *testing.T, scope, targets, approver string, applyEnabled, requireLogin bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "access-scope-set"}
|
||||
cmd.Flags().String("scope", scope, "")
|
||||
cmd.Flags().String("targets", targets, "")
|
||||
cmd.Flags().String("approver", approver, "")
|
||||
cmd.Flags().Bool("apply-enabled", applyEnabled, "")
|
||||
cmd.Flags().Bool("require-login", requireLogin, "")
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
func TestBuildAccessScopeBody_Branches(t *testing.T) {
|
||||
t.Run("invalid scope", func(t *testing.T) {
|
||||
if _, err := buildAccessScopeBody(testRuntimeAccessScope(t, "bogus", "", "", false, false)); err == nil {
|
||||
t.Error("unknown scope must error")
|
||||
}
|
||||
})
|
||||
t.Run("specific with all target kinds and approver", func(t *testing.T) {
|
||||
body, err := buildAccessScopeBody(testRuntimeAccessScope(t,
|
||||
"specific",
|
||||
`[{"type":"user","id":"u1"},{"type":"department","id":"d1"},{"type":"chat","id":"c1"}]`,
|
||||
"ou_appr", true, false))
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body["scope"] != "Range" {
|
||||
t.Errorf("scope=%v want Range", body["scope"])
|
||||
}
|
||||
for _, k := range []string{"users", "departments", "chats", "apply_config"} {
|
||||
if _, ok := body[k]; !ok {
|
||||
t.Errorf("missing %q in body=%v", k, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("specific with invalid targets JSON", func(t *testing.T) {
|
||||
if _, err := buildAccessScopeBody(testRuntimeAccessScope(t, "specific", "{bad", "", false, false)); err == nil {
|
||||
t.Error("invalid targets JSON must error")
|
||||
}
|
||||
})
|
||||
t.Run("public sets require_login", func(t *testing.T) {
|
||||
body, err := buildAccessScopeBody(testRuntimeAccessScope(t, "public", "", "", false, true))
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body["scope"] != "All" || body["require_login"] != true {
|
||||
t.Errorf("public body=%v", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_Specific(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
@@ -201,3 +254,44 @@ func TestAppsAccessScopeSet_TrimsAppIDInPath(t *testing.T) {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAccessScopeTargets_Partitions(t *testing.T) {
|
||||
users, departments, chats := splitAccessScopeTargets([]map[string]interface{}{
|
||||
{"type": "user", "id": "u1"},
|
||||
{"type": "department", "id": "d1"},
|
||||
{"type": "chat", "id": "c1"},
|
||||
{"type": "user", "id": " "}, // empty id skipped
|
||||
{"type": "unknown", "id": "x"}, // unknown type skipped
|
||||
})
|
||||
if len(users) != 1 || users[0] != "u1" {
|
||||
t.Errorf("users=%v want [u1]", users)
|
||||
}
|
||||
if len(departments) != 1 || departments[0] != "d1" {
|
||||
t.Errorf("departments=%v want [d1]", departments)
|
||||
}
|
||||
if len(chats) != 1 || chats[0] != "c1" {
|
||||
t.Errorf("chats=%v want [c1]", chats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTargetsJSON_Cases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantErr bool
|
||||
}{
|
||||
{"invalid json", "{not json", true},
|
||||
{"empty array", "[]", true},
|
||||
{"bad type", `[{"type":"role","id":"r1"}]`, true},
|
||||
{"empty id", `[{"type":"user","id":" "}]`, true},
|
||||
{"valid", `[{"type":"user","id":"u1"}]`, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := validateTargetsJSON(c.in)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Errorf("validateTargetsJSON(%q) err=%v wantErr=%v", c.in, err, c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
71
shortcuts/apps/apps_callapi_typed_test.go
Normal file
71
shortcuts/apps/apps_callapi_typed_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAppsList_503IsRetryableTypedError pins the typed-error upgrade: a 5xx
|
||||
// response from the apps list endpoint must surface as a typed errs.Problem with
|
||||
// Retryable == true (via CallAPITyped → httpStatusError). The pre-migration
|
||||
// CallAPI path produced a legacy *output.ExitError with no Retryable field, so
|
||||
// this test fails until AppsList is migrated to CallAPITyped.
|
||||
func TestAppsList_503IsRetryableTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Status: 503,
|
||||
// A gateway-style non-JSON body (text/html) forces the status-based
|
||||
// classifier (httpStatusError) rather than the API-envelope path.
|
||||
Headers: http.Header{"Content-Type": []string{"text/html"}},
|
||||
RawBody: []byte("<html><body>503 Service Unavailable</body></html>"),
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error on 503, got nil; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.Problem on 503, got %T: %v", err, err)
|
||||
}
|
||||
if !p.Retryable {
|
||||
t.Fatalf("expected Retryable == true on 503, got Problem=%+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsList_SuccessShapeUnchanged pins that the success path is
|
||||
// output-shape-neutral after migration: a 200 envelope still yields a success
|
||||
// stdout envelope carrying the app_id.
|
||||
func TestAppsList_SuccessShapeUnchanged(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"app_id": "a", "name": "n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"app_id": "a"`) {
|
||||
t.Fatalf("stdout missing app_id: %s", got)
|
||||
}
|
||||
}
|
||||
83
shortcuts/apps/apps_chat.go
Normal file
83
shortcuts/apps/apps_chat.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsChat sends a user message to a session, starting/continuing a conversation.
|
||||
// Async: the message is queued and the response carries no business payload (no
|
||||
// turn_id, no next_poll_after_ms — the turn is not generated yet). Poll
|
||||
// +session-get; it returns next_poll_after_ms, and once the turn runs its handle
|
||||
// is in latest_turn.turn_id.
|
||||
|
||||
// Turn cost varies sharply by init state: the first +chat on a not-initialized
|
||||
// app runs a one-time design + first-generation pass server-side (~20-50 min);
|
||||
// chat on an already-initialized app is incremental and finishes in minutes.
|
||||
// The init-state check and matching polling cadence live in the lark-apps
|
||||
// skill reference (references/lark-apps-cloud-dev.md) — the canonical source.
|
||||
var AppsChat = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+chat",
|
||||
Description: "Send a message to a session to start/continue a conversation",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message "做一个待办清单页面"`,
|
||||
`Example: lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message "把首页标题改为 我的待办"`,
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "session-id", Desc: "session ID", Required: true},
|
||||
{Name: "message", Desc: "user message text", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
}
|
||||
// Do not echo --message content in the error (spec §4 redaction).
|
||||
if strings.TrimSpace(rctx.Str("message")) == "" {
|
||||
return output.ErrValidation("--message is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST(chatPath(rctx.Str("app-id"), rctx.Str("session-id"))).
|
||||
Desc("Send a message to a session").
|
||||
Body(buildChatBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("POST", chatPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, buildChatBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, "if the session_id is unknown or invalid, list this app's sessions with `lark-cli apps +session-list --app-id "+strings.TrimSpace(rctx.Str("app-id"))+"`")
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "message sent; poll +session-get for turn status\n")
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func chatPath(appID, sessionID string) string {
|
||||
return sessionPath(appID, sessionID) + "/chat"
|
||||
}
|
||||
|
||||
func buildChatBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"message": strings.TrimSpace(rctx.Str("message")),
|
||||
}
|
||||
}
|
||||
104
shortcuts/apps/apps_chat_test.go
Normal file
104
shortcuts/apps/apps_chat_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsChat_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
// +chat is async and returns NO business payload (no turn_id, no
|
||||
// next_poll_after_ms — the turn is not generated yet). turn_id and the
|
||||
// poll interval are read later from +session-get.
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "把首页表头改成蓝色", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["message"] != "把首页表头改成蓝色" {
|
||||
t.Fatalf("body.message = %v", sent["message"])
|
||||
}
|
||||
if _, present := sent["attachment_ids"]; present {
|
||||
t.Fatalf("attachment_ids must not be sent this iteration: %v", sent)
|
||||
}
|
||||
// +chat carries no next_poll_after_ms; the CLI must not fabricate one.
|
||||
if got := stdout.String(); strings.Contains(got, "next_poll_after_ms") {
|
||||
t.Fatalf("stdout must not reference next_poll_after_ms (chat returns none): %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsChat_Pretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "hi", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "message sent") || !strings.Contains(got, "+session-get") {
|
||||
t.Fatalf("pretty wrong: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsChat_RequiresMessage(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "message") {
|
||||
t.Fatalf("expected --message required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Security: a non-blank message that fails for another reason must never be echoed.
|
||||
// Here we assert the blank-message error names the field only (no content leak path).
|
||||
func TestAppsChat_ValidationDoesNotEchoMessage(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// blank message triggers validation; the error must mention the flag, not any content.
|
||||
err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "", "--session-id", "conv_x", "--message", "secret-content-xyz", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-content-xyz") {
|
||||
t.Fatalf("validation error must not echo --message content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsChat_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "hi", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"message": "hi"`) {
|
||||
t.Fatalf("dry-run missing message body: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,24 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create Miaoda apps"
|
||||
|
||||
// AppsCreate creates a new Miaoda app.
|
||||
var AppsCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+create",
|
||||
Description: "Create a new Miaoda app",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
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 (currently only: HTML)", 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)"},
|
||||
},
|
||||
@@ -32,13 +38,6 @@ var AppsCreate = common.Shortcut{
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return output.ErrValidation("--name is required")
|
||||
}
|
||||
appType := strings.TrimSpace(rctx.Str("app-type"))
|
||||
if appType == "" {
|
||||
return output.ErrValidation("--app-type is required")
|
||||
}
|
||||
if !validAppTypes[appType] {
|
||||
return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML)", appType))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -48,9 +47,9 @@ var AppsCreate = common.Shortcut{
|
||||
Body(buildAppsCreateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPI("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
|
||||
data, err := rctx.CallAPITyped("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
return withAppsHint(err, createHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "created: %s\n", common.GetString(data, "app", "app_id"))
|
||||
@@ -59,15 +58,13 @@ var AppsCreate = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// 应用类型枚举。当前只有 HTML,未来会扩展(SPA、NATIVE、...)。
|
||||
var validAppTypes = map[string]bool{
|
||||
"HTML": true,
|
||||
}
|
||||
|
||||
func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
// --app-type is constrained to the lowercase enum (html / full_stack) by the
|
||||
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
|
||||
// a server concern and is intentionally not surfaced by the CLI.
|
||||
body := map[string]interface{}{
|
||||
"name": strings.TrimSpace(rctx.Str("name")),
|
||||
"app_type": strings.TrimSpace(rctx.Str("app-type")),
|
||||
"app_type": rctx.Str("app-type"),
|
||||
}
|
||||
if desc := strings.TrimSpace(rctx.Str("description")); desc != "" {
|
||||
body["description"] = desc
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
func newAppsExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
@@ -68,7 +69,7 @@ func TestAppsCreate_Success(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--description", "d", "--as", "user"},
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--description", "d", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -83,8 +84,8 @@ func TestAppsCreate_Success(t *testing.T) {
|
||||
if sent["name"] != "Demo" {
|
||||
t.Fatalf("body.name = %v", sent["name"])
|
||||
}
|
||||
if sent["app_type"] != "HTML" {
|
||||
t.Fatalf("body.app_type = %v (want HTML)", sent["app_type"])
|
||||
if sent["app_type"] != "html" {
|
||||
t.Fatalf("body.app_type = %v (want html)", sent["app_type"])
|
||||
}
|
||||
if sent["description"] != "d" {
|
||||
t.Fatalf("body.description = %v", sent["description"])
|
||||
@@ -108,7 +109,7 @@ func TestAppsCreate_WithIconURL(t *testing.T) {
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -133,7 +134,7 @@ func TestAppsCreate_PrettyOutputReadsNestedAppID(t *testing.T) {
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--format", "pretty", "--as", "user"},
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -144,7 +145,7 @@ func TestAppsCreate_PrettyOutputReadsNestedAppID(t *testing.T) {
|
||||
|
||||
func TestAppsCreate_RequiresName(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "HTML", "--as", "user"}, factory, stdout)
|
||||
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "html", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "name") {
|
||||
t.Fatalf("expected name required error, got %v", err)
|
||||
}
|
||||
@@ -159,20 +160,31 @@ func TestAppsCreate_RequiresAppType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCreate_RejectsInvalidAppType pins that --app-type is a strict
|
||||
// lowercase enum (html / full_stack). Unknown values and legacy uppercase are
|
||||
// both rejected by the flag's Enum — the CLI does not normalize case; legacy
|
||||
// uppercase compatibility is a server-side concern, not surfaced by the client.
|
||||
func TestAppsCreate_RejectsInvalidAppType(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "spa", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported") {
|
||||
t.Fatalf("expected unsupported app-type error, got %v", err)
|
||||
for _, appType := range []string{"spa", "HTML", "Full_Stack"} {
|
||||
t.Run(appType, func(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", appType, "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid value") {
|
||||
t.Fatalf("expected invalid-enum error for %q, got %v", appType, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "full_stack") {
|
||||
t.Fatalf("expected enum error to list allowed values, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--dry-run", "--as", "user"},
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -183,7 +195,55 @@ func TestAppsCreate_DryRun(t *testing.T) {
|
||||
if !strings.Contains(got, `"name": "Demo"`) {
|
||||
t.Fatalf("dry-run missing body: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"app_type": "HTML"`) {
|
||||
if !strings.Contains(got, `"app_type": "html"`) {
|
||||
t.Fatalf("dry-run missing app_type: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_FullstackSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_fs", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "full_stack", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["app_type"] != "full_stack" {
|
||||
t.Fatalf("body.app_type = %v (want full_stack)", sent["app_type"])
|
||||
}
|
||||
if _, present := sent["message"]; present {
|
||||
t.Fatalf("message should never be sent: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_FullstackDryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "full_stack", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"app_type": "full_stack"`) {
|
||||
t.Fatalf("dry-run missing app_type full_stack: %s", got)
|
||||
}
|
||||
if strings.Contains(got, `"message"`) {
|
||||
t.Fatalf("dry-run should not contain message: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
98
shortcuts/apps/apps_db_env_create.go
Normal file
98
shortcuts/apps/apps_db_env_create.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBEnvCreate creates a DB environment for a Miaoda app(拆分单库为 dev/online 多环境)。
|
||||
//
|
||||
// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。
|
||||
// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
|
||||
var AppsDBEnvCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-env-create",
|
||||
Description: "Create a DB environment (split single-env DB into dev/online, irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-create --env dev --sync-data --app-id <app_id> --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"},
|
||||
{Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appDbEnvCreatePath(appID)).
|
||||
Desc("Create Miaoda app DB environment").
|
||||
Body(buildDBEnvCreateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", appDbEnvCreatePath(appID), nil, buildDBEnvCreateBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvCreateHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderEnvCreatePretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildDBEnvCreateBody 构造 db 环境创建 body:sync_data(bool)。
|
||||
// --env 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。
|
||||
func buildDBEnvCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"sync_data": rctx.Bool("sync-data"),
|
||||
}
|
||||
}
|
||||
|
||||
// renderEnvCreatePretty 输出 4 行(pretty 模式):
|
||||
//
|
||||
// ✓ Multi-env initialized
|
||||
// Environments: dev, online
|
||||
// Data synced: yes
|
||||
// Note: structure changes in dev now need to be released to online.
|
||||
func renderEnvCreatePretty(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprintln(w, "✓ Multi-env initialized")
|
||||
|
||||
if envs, ok := data["environments"].([]interface{}); ok && len(envs) > 0 {
|
||||
names := make([]string, 0, len(envs))
|
||||
for _, e := range envs {
|
||||
if s, ok := e.(string); ok {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "Environments: %s\n", strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
synced := "no"
|
||||
if ds, ok := data["data_synced"].(bool); ok && ds {
|
||||
synced = "yes"
|
||||
}
|
||||
fmt.Fprintf(w, "Data synced: %s\n", synced)
|
||||
|
||||
fmt.Fprintln(w, "Note: structure changes in dev now need to be released to online.")
|
||||
}
|
||||
124
shortcuts/apps/apps_db_env_create_test.go
Normal file
124
shortcuts/apps/apps_db_env_create_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsDBEnvCreate_WithYesPostsSyncData(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init", // URL 仍走 db_dev_init,CLI 命令名 +db-env-create
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"status": "initialized",
|
||||
"environments": []interface{}{"dev", "online"},
|
||||
"data_synced": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["sync_data"] != true {
|
||||
t.Fatalf("body.sync_data = %v (want true)", sent["sync_data"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "initialized") {
|
||||
t.Fatalf("stdout should include status, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 不传 --sync-data(默认)→ body.sync_data=false
|
||||
func TestAppsDBEnvCreate_SyncDataFalseByDefault(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "initialized"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["sync_data"] != false {
|
||||
t.Fatalf("body.sync_data = %v (want false by default)", sent["sync_data"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"status": "initialized",
|
||||
"environments": []interface{}{"dev", "online"},
|
||||
"data_synced": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
wantLines := []string{
|
||||
"✓ Multi-env initialized",
|
||||
"Environments: dev, online",
|
||||
"Data synced: yes",
|
||||
"Note: structure changes in dev now need to be released to online.",
|
||||
}
|
||||
for _, line := range wantLines {
|
||||
if !strings.Contains(got, line) {
|
||||
t.Errorf("pretty output missing line %q\ngot:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/db_dev_init") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --env 只接受 dev:传 online 应被 enum 校验拒绝。
|
||||
func TestAppsDBEnvCreate_RejectsNonDevEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "online", "--yes", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "env") {
|
||||
t.Fatalf("expected env enum rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
520
shortcuts/apps/apps_db_execute.go
Normal file
520
shortcuts/apps/apps_db_execute.go
Normal file
@@ -0,0 +1,520 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBExecute executes SQL against a Miaoda app database.
|
||||
//
|
||||
// POST /apps/{app_id}/sql_commands,CLI 永远带 ?transactional=false 进入 DBA 模式
|
||||
// (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON)。
|
||||
//
|
||||
// pretty 渲染 6 种形态:
|
||||
// - 单 SELECT:表格(列间两空格、列对齐填充)
|
||||
// - 空 SELECT:`(0 rows)`
|
||||
// - 单 DML:`✓ N row(s) <verb>`(verb 跟 sql_type:INSERT→inserted/UPDATE→updated/DELETE→deleted)
|
||||
// - 单 DDL:`✓ DDL executed`
|
||||
// - 多语句全部成功:逐条 `Statement K: ✓ <summary>` + 末尾 `✓ N statements executed`
|
||||
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
|
||||
//
|
||||
// 失败语义:server 多语句失败仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
|
||||
// 后升级成 typed api_error(exit 非 0、detail 带 statement_index / completed / rolled_back),
|
||||
// 避免 agent 误判 ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
|
||||
// 落地,故 rolled_back=false(真机 boe 实证)。
|
||||
//
|
||||
// JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
|
||||
//
|
||||
// Risk: high-risk-write —— SQL 可含 DML/DDL,框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。
|
||||
//
|
||||
// SQL 来源二选一:--sql(内联文本,或 - 读 stdin)/ --file(.sql 文件路径,受 CLI 相对路径约束)。
|
||||
// --file 在 Validate 阶段读出内容、归一化到 --sql,下游统一从 rctx.Str("sql") 取。
|
||||
var AppsDBExecute = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-execute",
|
||||
Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +db-execute --app-id <app_id> --sql "SELECT * FROM orders LIMIT 10" --yes`,
|
||||
`Example: lark-cli apps +db-execute --app-id <app_id> --env dev --file ./migration.sql --yes`,
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.results[].sql_type'",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||
Input: []string{common.Stdin}},
|
||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||
{Name: "env", Default: "dev", Enum: []string{"dev", "online"}, Desc: "target db environment (default dev; use --env online for the online environment)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
sql := strings.TrimSpace(rctx.Str("sql"))
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
if sql != "" && file != "" {
|
||||
return output.ErrValidation("--sql and --file are mutually exclusive")
|
||||
}
|
||||
if file != "" {
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--file: %v", err)
|
||||
}
|
||||
// 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。
|
||||
rctx.Cmd.Flags().Set("sql", string(data))
|
||||
sql = strings.TrimSpace(string(data))
|
||||
}
|
||||
if sql == "" {
|
||||
return output.ErrValidation("one of --sql or --file is required (use --sql - to read stdin)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appSQLPath(appID)).
|
||||
Desc("Execute SQL on Miaoda app database").
|
||||
Params(buildDBSQLParams(rctx)).
|
||||
Body(buildDBSQLBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := rctx.CallAPITyped("POST", appSQLPath(appID),
|
||||
buildDBSQLParams(rctx),
|
||||
buildDBSQLBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table <table>`; for day-to-day debugging target the dev database with `--env dev`")
|
||||
}
|
||||
|
||||
// server `result: string` 内嵌结构化数组 —— CLI 解出来放进 envelope 的 data.results,
|
||||
// 让 json/pretty 路径都基于同一份反序列化产物渲染。
|
||||
stmts := parseSQLResult(common.GetString(raw, "result"))
|
||||
// 注意:data.results 在 json(默认)路径下原样透出全部行,CLI 侧不再二次截断。
|
||||
// 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出会直接
|
||||
// 返报错(而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。
|
||||
data := map[string]interface{}{"results": stmts}
|
||||
|
||||
// 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。
|
||||
// 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→stderr)。
|
||||
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
|
||||
if rctx.Format == "pretty" {
|
||||
renderSQLPretty(rctx.IO().Out, stmts)
|
||||
}
|
||||
return sqlStatementError(stmts, errIdx, errStmt)
|
||||
}
|
||||
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderSQLPretty(w, stmts)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// findErrorSentinel 在 statements 里找 ERROR 哨兵(server 失败时追加在失败语句位置)。
|
||||
// 返回失败语句下标(0-based)、该 ERROR statement、是否命中。
|
||||
func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interface{}, bool) {
|
||||
for i, s := range stmts {
|
||||
if common.GetString(s, "sql_type") == "ERROR" {
|
||||
return i, s, true
|
||||
}
|
||||
}
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
// sqlStatementError 把 ERROR 哨兵升级成 typed api_error。
|
||||
//
|
||||
// CLI 永远 DBA 模式(transactional=false),真机 boe 实证:失败语句之前的语句已逐条 auto-commit
|
||||
// 落地,不存在外层事务回滚。因此 rolled_back=false、completed 列出已落地的前序语句,hint 提示用户
|
||||
// 别整批重跑(否则会重复写入)。
|
||||
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
|
||||
code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
|
||||
stmtNo := errIdx + 1 // 1-based 给人看
|
||||
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))
|
||||
|
||||
apiErr := output.ErrAPI(code, fullMsg, map[string]interface{}{
|
||||
"statement_index": errIdx,
|
||||
"completed": stmts[:errIdx],
|
||||
"rolled_back": false,
|
||||
})
|
||||
if apiErr.Detail != nil {
|
||||
if errIdx > 0 {
|
||||
apiErr.Detail.Hint = fmt.Sprintf(
|
||||
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
|
||||
errIdx, stmtNo)
|
||||
} else {
|
||||
apiErr.Detail.Hint = "no statements were applied; fix the SQL and re-run."
|
||||
}
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
// parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。
|
||||
// code 兼容 int / "k_dl_1300002" / 数字字符串多形态(复用 codeString),解析失败回退 0 / 原文。
|
||||
func parseErrorSentinel(data string) (int, string) {
|
||||
if data == "" {
|
||||
return 0, "(unknown error)"
|
||||
}
|
||||
var e struct {
|
||||
Code interface{} `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &e); err != nil {
|
||||
return 0, data
|
||||
}
|
||||
code := 0
|
||||
if cs := codeString(e.Code); cs != "" {
|
||||
if n, convErr := strconv.Atoi(cs); convErr == nil {
|
||||
code = n
|
||||
}
|
||||
}
|
||||
if e.Message == "" {
|
||||
return code, "(unknown error)"
|
||||
}
|
||||
return code, e.Message
|
||||
}
|
||||
|
||||
// buildDBSQLParams 构造 sql 接口的 query:env + 强制 transactional=false(DBA 模式)。
|
||||
//
|
||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": rctx.Str("env"),
|
||||
"transactional": false,
|
||||
}
|
||||
}
|
||||
|
||||
// buildDBSQLBody 构造 sql 接口的 body:仅 sql(来源由 Validate 归一化到 --sql)。
|
||||
func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"sql": rctx.Str("sql"),
|
||||
}
|
||||
}
|
||||
|
||||
// parseSQLResult 从 server result 字符串反序列化出 statements 数组,兼容两种 wire 形态:
|
||||
//
|
||||
// 1. 结构化形态:`[{"sql_type":"SELECT","data":"[...]","record_count":N}, ...]`
|
||||
// —— 每条 statement 含 sql_type / data / record_count / affected_rows 元数据。
|
||||
//
|
||||
// 2. 字符串数组形态:`["[{...rows...}]", "", ...]`
|
||||
// —— 每条 statement 一个字符串:SELECT 是 rows JSON、DML/DDL 是空串;
|
||||
// 无 sql_type 元数据,CLI 端按内容形态推断(SELECT vs OK)。
|
||||
//
|
||||
// 解析失败时返回单元素 fallback `{sql_type:"RAW", data:resultStr}`,pretty 路径原样打。
|
||||
func parseSQLResult(resultStr string) []map[string]interface{} {
|
||||
if resultStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 形态 1:结构化数组(每元素是 object)
|
||||
var structured []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(resultStr), &structured); err == nil && isStructuredResult(structured) {
|
||||
return structured
|
||||
}
|
||||
|
||||
// 形态 2:字符串数组(每元素是 rows JSON 或 "")
|
||||
var legacy []string
|
||||
if err := json.Unmarshal([]byte(resultStr), &legacy); err == nil {
|
||||
out := make([]map[string]interface{}, 0, len(legacy))
|
||||
for _, rowsJSON := range legacy {
|
||||
out = append(out, normalizeLegacyStatement(rowsJSON))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
return []map[string]interface{}{{"sql_type": "RAW", "data": resultStr}}
|
||||
}
|
||||
|
||||
// isStructuredResult 判断反序列化出来的 []map 是不是新形态:第一条元素含 sql_type 字段。
|
||||
// 兼容场景:[]map 反序列化 legacy `[""]` 可能也能成(空 map),用 sql_type 存在性区分。
|
||||
func isStructuredResult(stmts []map[string]interface{}) bool {
|
||||
if len(stmts) == 0 {
|
||||
return false
|
||||
}
|
||||
_, ok := stmts[0]["sql_type"]
|
||||
return ok
|
||||
}
|
||||
|
||||
// normalizeLegacyStatement 把 legacy wire 一个字符串元素转成跟新形态一致的 map。
|
||||
// 推断规则:data 是非空 rows 数组 → sql_type=SELECT;空串 / 空数组 → sql_type=OK(DML/DDL 老 wire 不可分)。
|
||||
func normalizeLegacyStatement(rowsJSON string) map[string]interface{} {
|
||||
stmt := map[string]interface{}{
|
||||
"sql_type": "OK",
|
||||
"data": rowsJSON,
|
||||
}
|
||||
trimmed := strings.TrimSpace(rowsJSON)
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return stmt
|
||||
}
|
||||
var rows []interface{}
|
||||
if err := json.Unmarshal([]byte(trimmed), &rows); err != nil {
|
||||
// 非 JSON 数组(理论上 server 不会返这种),按原样保留 sql_type=OK
|
||||
return stmt
|
||||
}
|
||||
// 是 JSON 数组 → 视作 SELECT,含 record_count
|
||||
stmt["sql_type"] = "SELECT"
|
||||
stmt["record_count"] = float64(len(rows))
|
||||
return stmt
|
||||
}
|
||||
|
||||
// renderSQLPretty 按 statements 数量分单条 / 多条两种渲染路径。
|
||||
func renderSQLPretty(w io.Writer, stmts []map[string]interface{}) {
|
||||
if len(stmts) == 0 {
|
||||
fmt.Fprintln(w, "(empty result)")
|
||||
return
|
||||
}
|
||||
if len(stmts) == 1 {
|
||||
renderSingleStatementPretty(w, stmts[0])
|
||||
return
|
||||
}
|
||||
renderMultiStatementPretty(w, stmts)
|
||||
}
|
||||
|
||||
// renderSingleStatementPretty 单条 statement pretty(无 Statement header)。
|
||||
func renderSingleStatementPretty(w io.Writer, s map[string]interface{}) {
|
||||
sqlType := common.GetString(s, "sql_type")
|
||||
switch {
|
||||
case sqlType == "SELECT":
|
||||
renderSelectRowsAsTable(w, common.GetString(s, "data"))
|
||||
case sqlType == "ERROR":
|
||||
// 单条就挂的极端场景:直接打 ERROR 行(跟多语句失败的最后一行格式一致)。
|
||||
fmt.Fprintln(w, "✗ "+errorSummary(common.GetString(s, "data")))
|
||||
case isDMLType(sqlType):
|
||||
// 结构化 wire 下 INSERT / UPDATE / DELETE / MERGE:✓ N row(s) <verb>
|
||||
fmt.Fprintln(w, "✓ "+dmlSummary(sqlType, s["affected_rows"]))
|
||||
case sqlType == "OK":
|
||||
// legacy wire 下 DML / DDL 都映射成 OK(老 wire 不带 sql_type 元数据,无法区分动词 / 行数)
|
||||
fmt.Fprintln(w, "✓ ok")
|
||||
default:
|
||||
// 其余皆 DDL:真机 boe 返细粒度动词 CREATE_TABLE / DROP_TABLE / ALTER_TABLE / TRUNCATE 等。
|
||||
fmt.Fprintln(w, "✓ DDL executed")
|
||||
}
|
||||
}
|
||||
|
||||
// renderMultiStatementPretty 多条 statement pretty:
|
||||
// - 每条用 "Statement K: ✓ <summary>" / "Statement K: ✗ <error> [<code>]"
|
||||
// - SELECT 用 "Statement K: SELECT (N row(s))" 头 + 紧跟表格
|
||||
// - 末尾汇总:全部成功 "✓ N statements executed";遇 ERROR 哨兵打「前序语句已落地」提示
|
||||
// (DBA 模式不回滚),失败本身由 Execute 升级成 typed error(exit 非 0)
|
||||
func renderMultiStatementPretty(w io.Writer, stmts []map[string]interface{}) {
|
||||
failedIdx := -1
|
||||
successCount := 0
|
||||
for i, s := range stmts {
|
||||
sqlType := common.GetString(s, "sql_type")
|
||||
idx := i + 1
|
||||
switch {
|
||||
case sqlType == "ERROR":
|
||||
fmt.Fprintf(w, "Statement %d: ✗ %s\n", idx, errorSummary(common.GetString(s, "data")))
|
||||
failedIdx = i
|
||||
case sqlType == "SELECT":
|
||||
rc := intOrZero(s["record_count"])
|
||||
fmt.Fprintf(w, "Statement %d: SELECT (%d row%s)\n", idx, rc, plural(rc))
|
||||
renderSelectRowsAsTable(w, common.GetString(s, "data"))
|
||||
successCount++
|
||||
case isDMLType(sqlType):
|
||||
fmt.Fprintf(w, "Statement %d: ✓ %s\n", idx, dmlSummary(sqlType, s["affected_rows"]))
|
||||
successCount++
|
||||
case sqlType == "OK":
|
||||
fmt.Fprintf(w, "Statement %d: ✓ ok\n", idx)
|
||||
successCount++
|
||||
default:
|
||||
// DDL 族:CREATE_TABLE / DROP_TABLE / ALTER_TABLE / TRUNCATE / CREATE_INDEX ...
|
||||
fmt.Fprintf(w, "Statement %d: ✓ DDL executed\n", idx)
|
||||
successCount++
|
||||
}
|
||||
if i < len(stmts)-1 {
|
||||
fmt.Fprintln(w) // statements 间留空行
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if failedIdx >= 0 {
|
||||
// CLI 永远 DBA 模式(transactional=false),失败语句之前的语句已 auto-commit 落地,
|
||||
// 不存在整批回滚 —— 如实告诉用户,避免整批重跑导致重复写入。
|
||||
if successCount > 0 {
|
||||
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it already applied — DBA mode auto-commits each)\n",
|
||||
failedIdx+1, successCount, plural(int64(successCount)))
|
||||
} else {
|
||||
fmt.Fprintf(w, "(statement %d failed; no statements applied)\n", failedIdx+1)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(w, "✓ %d statements executed\n", successCount)
|
||||
}
|
||||
}
|
||||
|
||||
// renderSelectRowsAsTable 把 SELECT 的 data(rows JSON 数组字符串)解析并渲染成对齐表格。
|
||||
// 空结果输出 "(0 rows)"。
|
||||
func renderSelectRowsAsTable(w io.Writer, dataJSON string) {
|
||||
if dataJSON == "" || dataJSON == "[]" {
|
||||
fmt.Fprintln(w, "(0 rows)")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil {
|
||||
// 数据不符合预期 schema —— 原样打 fallback。
|
||||
fmt.Fprintln(w, dataJSON)
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintln(w, "(0 rows)")
|
||||
return
|
||||
}
|
||||
headers := collectColumns(rows)
|
||||
cells := make([][]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
line := make([]string, 0, len(headers))
|
||||
for _, h := range headers {
|
||||
line = append(line, cellString(row[h]))
|
||||
}
|
||||
cells = append(cells, line)
|
||||
}
|
||||
renderAlignedTable(w, headers, cells)
|
||||
}
|
||||
|
||||
// collectColumns 按首行字段顺序收集列名;首行 key 顺序由 encoding/json 反序列化决定(map 无序),
|
||||
// 排序后保证输出稳定。列顺序在示例里跟 SQL SELECT 顺序一致——但 Go encoding/json 反序列化丢列序,
|
||||
// 这里按字典序保证可重现,agent / 测试可稳定 assert。
|
||||
func collectColumns(rows []map[string]interface{}) []string {
|
||||
set := map[string]struct{}{}
|
||||
for _, r := range rows {
|
||||
for k := range r {
|
||||
set[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
cols := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
cols = append(cols, k)
|
||||
}
|
||||
sort.Strings(cols)
|
||||
return cols
|
||||
}
|
||||
|
||||
// cellString 把任意 JSON value 转字符串显示(null → 空串;非字符串/数字 → JSON 编码)。
|
||||
func cellString(v interface{}) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return x
|
||||
case bool:
|
||||
if x {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case float64:
|
||||
// 整数值不输出小数(id=101 而不是 101.000000)。
|
||||
if x == float64(int64(x)) {
|
||||
return fmt.Sprintf("%d", int64(x))
|
||||
}
|
||||
return fmt.Sprintf("%g", x)
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// dmlSummary 把 sql_type + affected_rows 渲染成 "N row(s) <verb>" 字符串。
|
||||
//
|
||||
// 动词映射:INSERT → inserted / UPDATE → updated / DELETE → deleted / MERGE → merged。
|
||||
// 未知 sql_type 默认 "affected"。
|
||||
func dmlSummary(sqlType string, affectedRows interface{}) string {
|
||||
n := intOrZero(affectedRows)
|
||||
verb := dmlVerb(sqlType)
|
||||
return fmt.Sprintf("%d row%s %s", n, plural(n), verb)
|
||||
}
|
||||
|
||||
// isDMLType 判断 sql_type 是否是行级 DML(带 affected_rows 语义)。
|
||||
// 真机 boe wire:SELECT 走表格、INSERT/UPDATE/DELETE/MERGE 走行数摘要、其余(CREATE_TABLE /
|
||||
// DROP_TABLE / ALTER_TABLE / TRUNCATE / CREATE_INDEX ...)一律按 DDL 处理。
|
||||
func isDMLType(sqlType string) bool {
|
||||
switch strings.ToUpper(sqlType) {
|
||||
case "INSERT", "UPDATE", "DELETE", "MERGE":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func dmlVerb(sqlType string) string {
|
||||
switch strings.ToUpper(sqlType) {
|
||||
case "INSERT":
|
||||
return "inserted"
|
||||
case "UPDATE":
|
||||
return "updated"
|
||||
case "DELETE":
|
||||
return "deleted"
|
||||
case "MERGE":
|
||||
return "merged"
|
||||
}
|
||||
return "affected"
|
||||
}
|
||||
|
||||
func plural(n int64) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
// errorSummary 从 ERROR 哨兵的 data 字段({code, message} JSON)解析出 "message [code]" 形态。
|
||||
// 解析失败时回退到原文。
|
||||
func errorSummary(data string) string {
|
||||
if data == "" {
|
||||
return "(unknown error)"
|
||||
}
|
||||
var e struct {
|
||||
Code interface{} `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &e); err != nil {
|
||||
return data
|
||||
}
|
||||
codeStr := codeString(e.Code)
|
||||
if codeStr != "" {
|
||||
return fmt.Sprintf("%s [%s]", e.Message, codeStr)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// codeString 处理 code 字段在 wire 上可能是 int / "k_dl_1300015" / 数字字符串等多形态。
|
||||
func codeString(c interface{}) string {
|
||||
switch x := c.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
// "k_dl_1300015" → 抽 1300015;纯数字保持原样。
|
||||
if strings.HasPrefix(x, "k_dl_") {
|
||||
return strings.TrimPrefix(x, "k_dl_")
|
||||
}
|
||||
return x
|
||||
case float64:
|
||||
return fmt.Sprintf("%d", int64(x))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// intOrZero 把 JSON number 转 int64;nil / 类型不匹配返回 0。
|
||||
func intOrZero(raw interface{}) int64 {
|
||||
if n, ok := numericAsFloat(raw); ok {
|
||||
return int64(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
797
shortcuts/apps/apps_db_execute_test.go
Normal file
797
shortcuts/apps/apps_db_execute_test.go
Normal file
@@ -0,0 +1,797 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
// DBA 模式 result:结构化数组 JSON 字符串
|
||||
"result": `[{"sql_type":"SELECT","data":"[{\"id\":101,\"total_cents\":2500}]","record_count":1}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// JSON envelope 应该把 result 字符串 parse 之后放进 data.results
|
||||
var env struct {
|
||||
Data struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data.Results) != 1 {
|
||||
t.Fatalf("data.results = %d items (want 1)", len(env.Data.Results))
|
||||
}
|
||||
if env.Data.Results[0]["sql_type"] != "SELECT" {
|
||||
t.Fatalf("results[0].sql_type = %v", env.Data.Results[0]["sql_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--env", "dev", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/sql_commands" {
|
||||
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
|
||||
}
|
||||
if env.API[0].Body["sql"] != "select 1" {
|
||||
t.Fatalf("body.sql = %v", env.API[0].Body["sql"])
|
||||
}
|
||||
if env.API[0].Params["env"] != "dev" {
|
||||
t.Fatalf("params.env = %v", env.API[0].Params["env"])
|
||||
}
|
||||
if env.API[0].Params["transactional"] != false {
|
||||
t.Fatalf("params.transactional = %v (want false, CLI is DBA mode)", env.API[0].Params["transactional"])
|
||||
}
|
||||
if _, ok := env.API[0].Body["transactional"]; ok {
|
||||
t.Fatalf("transactional should NOT be in body, got body=%v", env.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", " ", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--sql or --file") {
|
||||
t.Fatalf("expected empty-sql error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --sql 与 --file 互斥
|
||||
func TestAppsDBExecute_RejectsSQLAndFileTogether(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1", "--file", "x.sql", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("expected mutual-exclusion error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --file 读取相对路径 .sql 文件 → 内容进 body.sql(dry-run 验证)
|
||||
func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sqlPath := filepath.Join(dir, "m.sql")
|
||||
if err := os.WriteFile(sqlPath, []byte("SELECT 42 AS answer;\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 切到临时目录,使相对路径校验通过(CLI 仅接受 cwd 内相对路径)。
|
||||
// 用 os.Chdir + 还原而非 t.Chdir:后者要 Go 1.24,本仓库 go.mod 为 1.23。
|
||||
oldWD, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--app-id", "app_x", "--env", "dev", "--file", "m.sql", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.API[0].Body["sql"] != "SELECT 42 AS answer;\n" {
|
||||
t.Fatalf("body.sql = %v, want file content", env.API[0].Body["sql"])
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// legacy wire 形态测试 —— BOE server 实测返这种 ["rows-json-string", ...]
|
||||
// 形态而非 spec 里的 [{sql_type, data, ...}],CLI 端必须兼容。
|
||||
// 输入用 BOE 真实抓包数据(test_scripts/boe_e2e/run.log)。
|
||||
// ============================================================================
|
||||
|
||||
func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) {
|
||||
// BOE 实测:SELECT 1 AS x → result: "[\"[{\\\"x\\\":1}]\"]"
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `["[{\"x\":1}]"]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1 AS x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "x") {
|
||||
t.Errorf("missing header 'x':\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "1") {
|
||||
t.Errorf("missing value row '1':\n%s", got)
|
||||
}
|
||||
// 不应回退到 RAW
|
||||
if strings.Contains(got, "RAW") || strings.Contains(got, "[\\\"") {
|
||||
t.Errorf("should not fall back to RAW or raw-string passthrough:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) {
|
||||
// 验证 JSON envelope 也把 legacy result 正确归一化进 data.results
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `["[{\"x\":1}]"]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1 AS x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data.Results) != 1 {
|
||||
t.Fatalf("results length = %d, want 1; got: %v", len(env.Data.Results), env.Data.Results)
|
||||
}
|
||||
if env.Data.Results[0]["sql_type"] != "SELECT" {
|
||||
t.Fatalf("results[0].sql_type = %v, want SELECT", env.Data.Results[0]["sql_type"])
|
||||
}
|
||||
if env.Data.Results[0]["record_count"] != float64(1) {
|
||||
t.Fatalf("results[0].record_count = %v, want 1", env.Data.Results[0]["record_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) {
|
||||
// BOE 实测:SELECT 1; SELECT 2 → result: "[\"[{\\\"?column?\\\":1}]\",\"[{\\\"?column?\\\":2}]\"]"
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `["[{\"?column?\":1}]","[{\"?column?\":2}]"]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1; SELECT 2;", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// 多语句应有 Statement N: header
|
||||
if !strings.Contains(got, "Statement 1: SELECT") || !strings.Contains(got, "Statement 2: SELECT") {
|
||||
t.Errorf("missing Statement headers:\n%s", got)
|
||||
}
|
||||
// 末尾应有 ✓ N statements executed
|
||||
if !strings.Contains(got, "✓ 2 statements executed") {
|
||||
t.Errorf("missing summary line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) {
|
||||
// BOE 实测:CREATE TABLE → result: "" (空字符串,无 rows)
|
||||
// 老 wire 不区分 DDL/DML/无返回,统一标 "ok"
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": ``, // 空字符串
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "CREATE TABLE foo (id INT)", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// result="" 触发 parseSQLResult 返 nil → renderSQLPretty 输出 "(empty result)"
|
||||
if !strings.Contains(got, "(empty result)") {
|
||||
t.Errorf("expected '(empty result)' for empty result string, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBExecute_LegacyWireMultiSelectWithRealTable(t *testing.T) {
|
||||
// BOE 实测真实表抓包(course 表第一行):复杂 JSON 含 CJK / timestamp / uuid 字段
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `["[{\"id\":\"abc-123\",\"title\":\"高效沟通\",\"capacity\":30}]"]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT id,title,capacity FROM course LIMIT 1", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// 验证 CJK / uuid / int 都能正确显示在表格里
|
||||
for _, want := range []string{"id", "title", "capacity", "abc-123", "高效沟通", "30"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in pretty output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pretty 单 SELECT:表格输出,列间两空格,无 Statement header。
|
||||
func TestAppsDBExecute_PrettySingleSelectTable(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"SELECT","data":"[{\"id\":101,\"total_cents\":2500},{\"id\":102,\"total_cents\":1800}]","record_count":2}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if strings.Contains(got, "Statement 1:") {
|
||||
t.Errorf("single statement pretty should NOT have Statement header\noutput:\n%s", got)
|
||||
}
|
||||
// 列按字典序排序:id / total_cents
|
||||
if !strings.Contains(got, "id total_cents") {
|
||||
t.Errorf("missing header row\noutput:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "101 2500") || !strings.Contains(got, "102 1800") {
|
||||
t.Errorf("missing data rows\noutput:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"SELECT","data":"[]","record_count":0}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "(0 rows)") {
|
||||
t.Fatalf("empty SELECT should print (0 rows), got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result string
|
||||
wantStr string
|
||||
}{
|
||||
{"INSERT_1_row", `[{"sql_type":"INSERT","data":"","affected_rows":1}]`, "✓ 1 row inserted"},
|
||||
{"UPDATE_5_rows", `[{"sql_type":"UPDATE","data":"","affected_rows":5}]`, "✓ 5 rows updated"},
|
||||
{"DELETE_0_rows", `[{"sql_type":"DELETE","data":"","affected_rows":0}]`, "✓ 0 rows deleted"},
|
||||
{"DDL", `[{"sql_type":"DDL","data":"","affected_rows":0}]`, "✓ DDL executed"},
|
||||
// 真机 boe 实测:DDL 的 sql_type 是细粒度动词(CREATE_TABLE / DROP_TABLE / ALTER_TABLE...),
|
||||
// data 是 "[]"、无 affected_rows。必须识别为 DDL,而不是落到 dmlSummary 渲染成 "0 rows affected"。
|
||||
{"CREATE_TABLE", `[{"sql_type":"CREATE_TABLE","data":"[]"}]`, "✓ DDL executed"},
|
||||
{"DROP_TABLE", `[{"sql_type":"DROP_TABLE","data":"[]"}]`, "✓ DDL executed"},
|
||||
{"ALTER_TABLE", `[{"sql_type":"ALTER_TABLE","data":"[]"}]`, "✓ DDL executed"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"result": c.result}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), c.wantStr) {
|
||||
t.Errorf("want %q\ngot:\n%s", c.wantStr, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBExecute_PrettyMultiStatementsAllSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[` +
|
||||
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"UPDATE","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"SELECT","data":"[{\"id\":999}]","record_count":1}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, line := range []string{
|
||||
"Statement 1: ✓ 1 row inserted",
|
||||
"Statement 2: ✓ 1 row updated",
|
||||
"Statement 3: SELECT (1 row)",
|
||||
"✓ 3 statements executed",
|
||||
} {
|
||||
if !strings.Contains(got, line) {
|
||||
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyMultiStatementsDDL 钉住真机 boe 多语句 DDL 的 wire:
|
||||
// CREATE_TABLE / DROP_TABLE(data="[]"、无 affected_rows)须渲染成 "✓ DDL executed",
|
||||
// 不能落到 dmlSummary 变成 "0 rows affected"。
|
||||
func TestAppsDBExecute_PrettyMultiStatementsDDL(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"CREATE_TABLE","data":"[]"},{"sql_type":"DROP_TABLE","data":"[]"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, line := range []string{
|
||||
"Statement 1: ✓ DDL executed",
|
||||
"Statement 2: ✓ DDL executed",
|
||||
"✓ 2 statements executed",
|
||||
} {
|
||||
if !strings.Contains(got, line) {
|
||||
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "rows affected") {
|
||||
t.Errorf("DDL must not render as 'rows affected'\nfull:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[` +
|
||||
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"ERROR","data":"{\"code\":1300015,\"message\":\"syntax error at or near 'SELEC'\"}"}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
// pretty 失败路径:逐条 ✓/✗ 摘要照打到 stdout(人看),同时返回 typed error(exit 非 0)。
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("pretty multi-statement failure must still return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, line := range []string{
|
||||
"Statement 1: ✓ 1 row inserted",
|
||||
"Statement 2: ✗ syntax error at or near 'SELEC' [1300015]",
|
||||
} {
|
||||
if !strings.Contains(got, line) {
|
||||
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
// DBA 模式(transactional=false)前序语句已 auto-commit 落地,绝不能误报「rolled back」。
|
||||
if strings.Contains(got, "rolled back") {
|
||||
t.Errorf("DBA mode must NOT claim rollback (prior statements persisted); got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "statements executed") {
|
||||
t.Errorf("failed run should NOT print success summary; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed api_error」:
|
||||
// json 默认不再打 ok:true 假成功,而是返回 *output.ExitError(type=api_error、非零 exit),
|
||||
// detail 带 statement_index / completed / rolled_back。rolled_back=false 因 CLI 永远 DBA 模式
|
||||
// (真机 boe 实证:失败前的语句已落地)。
|
||||
func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[` +
|
||||
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"ERROR","data":"{\"code\":\"k_dl_1300002\",\"message\":\"duplicate key value violates unique constraint\"}"}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
// json 失败路径不得打成功 envelope。
|
||||
if strings.Contains(stdout.String(), `"ok": true`) {
|
||||
t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "api_error" {
|
||||
t.Errorf("error.type = %q, want api_error", exitErr.Detail.Type)
|
||||
}
|
||||
if exitErr.Detail.Code != 1300002 {
|
||||
t.Errorf("error.code = %d, want 1300002", exitErr.Detail.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "(at statement 2 of 2)") {
|
||||
t.Errorf("error.message missing statement locator: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("error.detail not a map: %T", exitErr.Detail.Detail)
|
||||
}
|
||||
if detail["statement_index"] != 1 {
|
||||
t.Errorf("statement_index = %v, want 1", detail["statement_index"])
|
||||
}
|
||||
if detail["rolled_back"] != false {
|
||||
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", detail["rolled_back"])
|
||||
}
|
||||
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 1 {
|
||||
t.Errorf("completed = %v, want 1 persisted statement", detail["completed"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败(server 也返 code:0 + ERROR 哨兵)
|
||||
// 同样升级成 typed error:statement_index=0、completed 空、message 标注 (at statement 1 of 1)。
|
||||
func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error at or near 'SELEC'\"}"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "(at statement 1 of 1)") {
|
||||
t.Errorf("error.message missing locator: %q", exitErr.Detail.Message)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if detail["statement_index"] != 0 {
|
||||
t.Errorf("statement_index = %v, want 0", detail["statement_index"])
|
||||
}
|
||||
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 0 {
|
||||
t.Errorf("completed = %v, want empty", detail["completed"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellString_AllKinds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in interface{}
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"string", "hello", "hello"},
|
||||
{"bool true", true, "true"},
|
||||
{"bool false", false, "false"},
|
||||
{"int float", float64(101), "101"},
|
||||
{"fractional", float64(1.25), "1.25"},
|
||||
{"object", map[string]interface{}{"a": float64(1)}, `{"a":1}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := cellString(c.in); got != c.want {
|
||||
t.Errorf("cellString(%v)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeString_Forms(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in interface{}
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"k_dl prefix", "k_dl_1300015", "1300015"},
|
||||
{"plain string", "1300015", "1300015"},
|
||||
{"float64", float64(42), "42"},
|
||||
{"unsupported", []int{1}, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := codeString(c.in); got != c.want {
|
||||
t.Errorf("codeString(%v)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDmlVerb_AllVerbs(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"INSERT": "inserted",
|
||||
"update": "updated",
|
||||
"DELETE": "deleted",
|
||||
"Merge": "merged",
|
||||
"CREATE_TABLE": "affected",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := dmlVerb(in); got != want {
|
||||
t.Errorf("dmlVerb(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntOrZero_Cases(t *testing.T) {
|
||||
if got := intOrZero(float64(5)); got != 5 {
|
||||
t.Errorf("intOrZero(5)=%d want 5", got)
|
||||
}
|
||||
if got := intOrZero("x"); got != 0 {
|
||||
t.Errorf("intOrZero(non-numeric)=%d want 0", got)
|
||||
}
|
||||
if got := intOrZero(nil); got != 0 {
|
||||
t.Errorf("intOrZero(nil)=%d want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorSummary_Cases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, in, want string
|
||||
}{
|
||||
{"empty", "", "(unknown error)"},
|
||||
{"malformed json", "not json", "not json"},
|
||||
{"with code", `{"code":"k_dl_1300015","message":"boom"}`, "boom [1300015]"},
|
||||
{"no code", `{"message":"plain"}`, "plain"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := errorSummary(c.in); got != c.want {
|
||||
t.Errorf("errorSummary(%q)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseErrorSentinel_Cases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, in string
|
||||
wantCode int
|
||||
wantMsg string
|
||||
}{
|
||||
{"empty", "", 0, "(unknown error)"},
|
||||
{"malformed", "xyz", 0, "xyz"},
|
||||
{"code+msg", `{"code":"1300015","message":"boom"}`, 1300015, "boom"},
|
||||
{"empty msg", `{"code":"1300015","message":""}`, 1300015, "(unknown error)"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
code, msg := parseErrorSentinel(c.in)
|
||||
if code != c.wantCode || msg != c.wantMsg {
|
||||
t.Errorf("parseErrorSentinel(%q)=%d,%q want %d,%q", c.in, code, msg, c.wantCode, c.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStructuredResult_Cases(t *testing.T) {
|
||||
if !isStructuredResult([]map[string]interface{}{{"sql_type": "SELECT"}}) {
|
||||
t.Error("expected structured=true when sql_type present")
|
||||
}
|
||||
if isStructuredResult([]map[string]interface{}{{}}) {
|
||||
t.Error("expected structured=false when sql_type absent")
|
||||
}
|
||||
if isStructuredResult(nil) {
|
||||
t.Error("expected structured=false for empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLegacyStatement_Cases(t *testing.T) {
|
||||
t.Run("empty -> OK", func(t *testing.T) {
|
||||
got := normalizeLegacyStatement("")
|
||||
if got["sql_type"] != "OK" {
|
||||
t.Errorf("got sql_type=%v want OK", got["sql_type"])
|
||||
}
|
||||
})
|
||||
t.Run("null -> OK", func(t *testing.T) {
|
||||
got := normalizeLegacyStatement("null")
|
||||
if got["sql_type"] != "OK" {
|
||||
t.Errorf("got sql_type=%v want OK", got["sql_type"])
|
||||
}
|
||||
})
|
||||
t.Run("rows -> SELECT", func(t *testing.T) {
|
||||
got := normalizeLegacyStatement(`[{"id":1}]`)
|
||||
if got["sql_type"] != "SELECT" {
|
||||
t.Errorf("got sql_type=%v want SELECT", got["sql_type"])
|
||||
}
|
||||
if got["record_count"] != float64(1) {
|
||||
t.Errorf("got record_count=%v want 1", got["record_count"])
|
||||
}
|
||||
})
|
||||
t.Run("non-json kept as OK", func(t *testing.T) {
|
||||
got := normalizeLegacyStatement(`notjson`)
|
||||
if got["sql_type"] != "OK" {
|
||||
t.Errorf("got sql_type=%v want OK", got["sql_type"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCellString_MarshalFallback(t *testing.T) {
|
||||
// complex128 is not switch-handled and json.Marshal rejects it →
|
||||
// falls back to fmt.Sprintf("%v", v), which is deterministic for complex.
|
||||
if got := cellString(complex(1, 2)); got != "(1+2i)" {
|
||||
t.Errorf("cellString(complex)=%q want (1+2i)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSingleStatementPretty_Branches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stmt map[string]interface{}
|
||||
substr string
|
||||
}{
|
||||
{"select empty", map[string]interface{}{"sql_type": "SELECT", "data": "[]"}, "(0 rows)"},
|
||||
{"error", map[string]interface{}{"sql_type": "ERROR", "data": `{"message":"boom"}`}, "✗ boom"},
|
||||
{"dml insert", map[string]interface{}{"sql_type": "INSERT", "affected_rows": float64(3)}, "✓ 3 rows inserted"},
|
||||
{"legacy ok", map[string]interface{}{"sql_type": "OK"}, "✓ ok"},
|
||||
{"ddl default", map[string]interface{}{"sql_type": "CREATE_TABLE"}, "✓ DDL executed"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
renderSingleStatementPretty(&b, c.stmt)
|
||||
if !strings.Contains(b.String(), c.substr) {
|
||||
t.Errorf("output %q does not contain %q", b.String(), c.substr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data string
|
||||
substr string
|
||||
}{
|
||||
{"empty string", "", "(0 rows)"},
|
||||
{"empty array", "[]", "(0 rows)"},
|
||||
{"malformed fallback", "{bad", "{bad"},
|
||||
{"rows", `[{"id":1}]`, "id"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
renderSelectRowsAsTable(&b, c.data)
|
||||
if !strings.Contains(b.String(), c.substr) {
|
||||
t.Errorf("output %q does not contain %q", b.String(), c.substr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
87
shortcuts/apps/apps_db_table_get.go
Normal file
87
shortcuts/apps/apps_db_table_get.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbTableGetHint = "verify --app-id and --table are correct; list tables with `lark-cli apps +db-table-list --app-id <app_id>`; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBTableGet gets one table's structure (动词对齐 +db-table-list)。
|
||||
//
|
||||
// GET /apps/{app_id}/tables/{table_name}。
|
||||
//
|
||||
// `--format` 同时驱动 CLI 渲染和 server 请求形态:
|
||||
// - `--format json`(默认)/ table / ndjson / csv:CLI 不传 format query,response 含结构化
|
||||
// columns / indexes / constraints / stats,envelope 化输出。
|
||||
// - `--format pretty`:CLI 给 server 带 ?format=ddl,response 含 ddl 字符串,stdout 直接打
|
||||
// ddl 内容(无 envelope / 无表格包装)。
|
||||
var AppsDBTableGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-table-get",
|
||||
Description: "Get a table's structure: columns, indexes and constraints",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-table-get --app-id <app_id> --table <table>",
|
||||
"Tip: filter fields with --jq (json format), e.g. -q '.data.columns[].name'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table name", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("table")) == "" {
|
||||
return output.ErrValidation("--table is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appTablePath(appID, strings.TrimSpace(rctx.Str("table")))).
|
||||
Desc("Get Miaoda app db table schema").
|
||||
Params(buildDBTableGetParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := appTablePath(appID, strings.TrimSpace(rctx.Str("table")))
|
||||
data, err := rctx.CallAPITyped("GET", path, buildDBTableGetParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbTableGetHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
// pretty 模式:stdout 直接打 ddl 文本(无 trailing newline,由 server 返回的字符串决定)。
|
||||
io.WriteString(w, common.GetString(data, "ddl"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildDBTableGetParams 构造 schema 接口的 query。
|
||||
//
|
||||
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
||||
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
||||
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": rctx.Str("env")}
|
||||
if rctx.Format == "pretty" {
|
||||
params["format"] = "ddl"
|
||||
}
|
||||
return params
|
||||
}
|
||||
131
shortcuts/apps/apps_db_table_get_test.go
Normal file
131
shortcuts/apps/apps_db_table_get_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsDBTableGet_DefaultJSONReturnsStructuredFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/tables/orders",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"name": "orders",
|
||||
"description": "订单表",
|
||||
"columns": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "id", "data_type": "int8",
|
||||
"is_primary_key": true, "is_unique": true,
|
||||
"is_allow_null": false, "default_value": "",
|
||||
},
|
||||
},
|
||||
"indexes": []interface{}{
|
||||
map[string]interface{}{"name": "orders_pkey", "type": "btree", "columns": []interface{}{"id"}, "definition": "..."},
|
||||
},
|
||||
"constraints": []interface{}{
|
||||
map[string]interface{}{"type": "primary_key", "name": "orders_pkey", "columns": []interface{}{"id"}},
|
||||
},
|
||||
"estimated_row_count": 1200,
|
||||
"size_bytes": 81920,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsDBTableGet,
|
||||
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"name": "orders"`) {
|
||||
t.Fatalf("stdout missing schema name: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --format pretty 是触发 DDL 模式的唯一开关。
|
||||
// 用 --format json + --dry-run 走 JSON envelope 路径方便 parse,但 query 形态由代码内部
|
||||
// 根据 rctx.Format 决定 —— 这里我们直接传 --format pretty + --dry-run,pretty 模式下 dry-run
|
||||
// 输出是 plain text 列表,用 substring 校验 format=ddl 出现在 URL query 中。
|
||||
func TestAppsDBTableGet_PrettyFormatSendsFormatDDLQuery(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBTableGet,
|
||||
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/tables/orders") {
|
||||
t.Fatalf("missing URL in dry-run output:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "format=ddl") {
|
||||
t.Fatalf("--format=pretty should trigger ?format=ddl, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableGet_NonPrettyFormatsOmitFormatQuery(t *testing.T) {
|
||||
// 默认 json / table / ndjson / csv 都走 schema 路径 —— CLI 不传 format query。
|
||||
for _, format := range []string{"json", "table", "ndjson", "csv"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
args := []string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", format, "--dry-run", "--as", "user"}
|
||||
if err := runAppsShortcut(t, AppsDBTableGet, args, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, ok := env.API[0].Params["format"]; ok {
|
||||
t.Fatalf("--format=%s should omit format query, got %v", format, env.API[0].Params)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableGet_PrettyOutputIsDDLTextOnly(t *testing.T) {
|
||||
// pretty 模式 stdout 直接打 ddl 字段文本,无 envelope / 表格包装。
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
ddl := "CREATE TABLE orders (\n id bigint NOT NULL,\n PRIMARY KEY (id)\n);"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/tables/orders",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ddl": ddl},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsDBTableGet,
|
||||
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "CREATE TABLE orders") {
|
||||
t.Fatalf("pretty output should contain raw DDL, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, `"data":`) || strings.Contains(got, `"ddl":`) {
|
||||
t.Fatalf("pretty output should not be JSON envelope, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableGet_RequiresTable(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBTableGet,
|
||||
[]string{"+db-table-get", "--app-id", "app_x", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "table") {
|
||||
t.Fatalf("expected table required error, got %v", err)
|
||||
}
|
||||
}
|
||||
301
shortcuts/apps/apps_db_table_list.go
Normal file
301
shortcuts/apps/apps_db_table_list.go
Normal file
@@ -0,0 +1,301 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbTableListHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBTableList lists tables in a Miaoda app's database.
|
||||
//
|
||||
// GET /apps/{app_id}/tables(cursor 分页),response items[] 含 estimated_row_count /
|
||||
// size_bytes optional 字段,默认返回,不必额外传 query。
|
||||
//
|
||||
// 输出裁剪:server 给每张表回完整 columns[](与 +db-table-get 同源、内容一致)。CLI 用白名单
|
||||
// 投影(dbTableListItem)只组装产品要求字段、把 columns[] 折算成 column_count,避免逐表重复列定义
|
||||
// 放大 token、并与 +db-table-get 职责区分。完整列定义 / 索引 / 约束 / DDL 用 +db-table-get。
|
||||
//
|
||||
// pretty 渲染 5 列:name / description / estimated_row_count / size / columns(即 column_count);
|
||||
// 列间两空格、列对齐填充、空 description 用 "—" 占位、size 按 KB/MB/GB 友好格式化。
|
||||
var AppsDBTableList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-table-list",
|
||||
Description: "List tables in a Miaoda app database (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-table-list --app-id <app_id>",
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.items[].name'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appTablesPath(appID)).
|
||||
Desc("List Miaoda app db tables").
|
||||
Params(buildDBTableListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appTablesPath(appID), buildDBTableListParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbTableListHint)
|
||||
}
|
||||
// 白名单投影:只把产品要求的字段组装进 dbTableListItem,替换 server 原始 items[]。
|
||||
// server 给每张表回完整 columns[](与 +db-table-get 同源、逐字节一致),在 list 里逐表
|
||||
// 重复既放大 token 又与 schema 职责重叠。这里用白名单而非 delete 黑名单 —— server 后续新增
|
||||
// 字段不会自动泄漏进 CLI 输出。需要完整列定义 / 索引 / 约束 / DDL 用 +db-table-get。
|
||||
items := projectTableListItems(data["items"])
|
||||
data["items"] = items
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderTableListPretty(w, items)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// dbTableListItem 是 +db-table-list 对外输出的「产品要求字段」白名单。
|
||||
// 改字段在此处增删即可,无需在 Execute 里逐个 delete server 返回的多余字段。
|
||||
type dbTableListItem struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
EstimatedRowCount interface{} `json:"estimated_row_count,omitempty"`
|
||||
SizeBytes interface{} `json:"size_bytes,omitempty"`
|
||||
ColumnCount int `json:"column_count"`
|
||||
}
|
||||
|
||||
// projectTableListItems 把 server 原始 items[](map)投影成白名单 dbTableListItem 切片。
|
||||
// column_count 由 server 返回的 columns[] 长度派生(随后 columns[] 不再透出)。
|
||||
func projectTableListItems(raw interface{}) []dbTableListItem {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]dbTableListItem, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, dbTableListItem{
|
||||
Name: common.GetString(m, "name"),
|
||||
Description: common.GetString(m, "description"),
|
||||
EstimatedRowCount: m["estimated_row_count"],
|
||||
SizeBytes: m["size_bytes"],
|
||||
ColumnCount: deriveColumnCount(m),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": rctx.Str("env"),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// renderTableListPretty 5 列输出,列间两空格、列对齐填充。
|
||||
//
|
||||
// 列名:name / description / estimated_row_count / size / columns。
|
||||
// 空 description 用 "—" 占位;size 由 size_bytes 经 humanBytes 友好格式化;
|
||||
// columns 列取白名单投影的 column_count。
|
||||
func renderTableListPretty(w io.Writer, items []dbTableListItem) {
|
||||
headers := []string{"name", "description", "estimated_row_count", "size", "columns"}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
desc := item.Description
|
||||
if desc == "" {
|
||||
desc = "—"
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
item.Name,
|
||||
desc,
|
||||
intString(item.EstimatedRowCount),
|
||||
humanBytes(item.SizeBytes),
|
||||
fmt.Sprintf("%d", item.ColumnCount),
|
||||
})
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
}
|
||||
|
||||
// renderAlignedTable 输出列对齐表格:列间两空格、列宽按每列最长 cell 填充、
|
||||
// 不画 `|` 和 `-` 分隔线、不依赖 TTY 着色。
|
||||
func renderAlignedTable(w io.Writer, headers []string, rows [][]string) {
|
||||
if len(headers) == 0 {
|
||||
return
|
||||
}
|
||||
widths := make([]int, len(headers))
|
||||
for i, h := range headers {
|
||||
widths[i] = displayWidth(h)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
if i >= len(widths) {
|
||||
break
|
||||
}
|
||||
if dw := displayWidth(cell); dw > widths[i] {
|
||||
widths[i] = dw
|
||||
}
|
||||
}
|
||||
}
|
||||
writeRow := func(cells []string) {
|
||||
for i, cell := range cells {
|
||||
if i >= len(widths) {
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
io.WriteString(w, " ")
|
||||
}
|
||||
io.WriteString(w, cell)
|
||||
if i < len(widths)-1 {
|
||||
pad := widths[i] - displayWidth(cell)
|
||||
if pad > 0 {
|
||||
io.WriteString(w, strings.Repeat(" ", pad))
|
||||
}
|
||||
}
|
||||
}
|
||||
io.WriteString(w, "\n")
|
||||
}
|
||||
writeRow(headers)
|
||||
for _, r := range rows {
|
||||
writeRow(r)
|
||||
}
|
||||
}
|
||||
|
||||
// displayWidth 估算字符串在 monospace 终端下的显示宽度。
|
||||
// ASCII 占 1 列;CJK / 全角字符占 2 列;其他多字节字符按 rune 数算(保守)。
|
||||
func displayWidth(s string) int {
|
||||
w := 0
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r < 0x80:
|
||||
w++
|
||||
case isWide(r):
|
||||
w += 2
|
||||
default:
|
||||
w++
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func isWide(r rune) bool {
|
||||
switch {
|
||||
case r >= 0x1100 && r <= 0x115F: // Hangul Jamo
|
||||
case r >= 0x2E80 && r <= 0x303E: // CJK Radicals / Kangxi
|
||||
case r >= 0x3041 && r <= 0x33FF: // Hiragana / Katakana / Bopomofo / CJK Symbols
|
||||
case r >= 0x3400 && r <= 0x4DBF: // CJK Extension A
|
||||
case r >= 0x4E00 && r <= 0x9FFF: // CJK Unified Ideographs
|
||||
case r >= 0xA000 && r <= 0xA4CF: // Yi
|
||||
case r >= 0xAC00 && r <= 0xD7A3: // Hangul Syllables
|
||||
case r >= 0xF900 && r <= 0xFAFF: // CJK Compatibility Ideographs
|
||||
case r >= 0xFE30 && r <= 0xFE4F: // CJK Compatibility Forms
|
||||
case r >= 0xFF00 && r <= 0xFF60: // Fullwidth Forms
|
||||
case r >= 0xFFE0 && r <= 0xFFE6: // Fullwidth Signs
|
||||
case r >= 0x20000 && r <= 0x2FFFD: // CJK Extension B-F
|
||||
case r >= 0x30000 && r <= 0x3FFFD: // CJK Extension G
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// humanBytes 把 size_bytes 数值转 KB / MB / GB 友好字符串。
|
||||
// 1 KiB = 1024 B;与 PG / 操作系统约定一致。
|
||||
func humanBytes(raw interface{}) string {
|
||||
n, ok := numericAsFloat(raw)
|
||||
if !ok {
|
||||
return "—"
|
||||
}
|
||||
const unit = 1024.0
|
||||
switch {
|
||||
case n < unit:
|
||||
return fmt.Sprintf("%d B", int64(n))
|
||||
case n < unit*unit:
|
||||
return fmt.Sprintf("%.0f KB", n/unit)
|
||||
case n < unit*unit*unit:
|
||||
return formatFloat(n/(unit*unit)) + " MB"
|
||||
default:
|
||||
return formatFloat(n/(unit*unit*unit)) + " GB"
|
||||
}
|
||||
}
|
||||
|
||||
// formatFloat 一位小数;整数值省略小数(24 KB 而不是 24.0 KB;1.5 MB 而不是 1 MB)。
|
||||
func formatFloat(f float64) string {
|
||||
if f == float64(int64(f)) {
|
||||
return fmt.Sprintf("%d", int64(f))
|
||||
}
|
||||
return fmt.Sprintf("%.1f", f)
|
||||
}
|
||||
|
||||
// intString 把 JSON 反序列化进来的 number 转为整数字符串显示(estimated_row_count)。
|
||||
func intString(raw interface{}) string {
|
||||
if n, ok := numericAsFloat(raw); ok {
|
||||
return fmt.Sprintf("%d", int64(n))
|
||||
}
|
||||
return "—"
|
||||
}
|
||||
|
||||
func numericAsFloat(raw interface{}) (float64, bool) {
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case uint:
|
||||
return float64(v), true
|
||||
case uint32:
|
||||
return float64(v), true
|
||||
case uint64:
|
||||
return float64(v), true
|
||||
case json.Number:
|
||||
f, err := v.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case nil:
|
||||
return 0, false
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// deriveColumnCount 从 items[i].columns 数组长度派生 column_count。
|
||||
func deriveColumnCount(m map[string]interface{}) int {
|
||||
cols, ok := m["columns"].([]interface{})
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return len(cols)
|
||||
}
|
||||
309
shortcuts/apps/apps_db_table_list_test.go
Normal file
309
shortcuts/apps/apps_db_table_list_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope 验证 server 业务错误
|
||||
// (code != 0,如单环境 app 查 env=dev 返 "Invalid DB Branch")被 CLI 透出成
|
||||
// typed error —— 用 BOE 实测的错误码 / 文案做输入。
|
||||
//
|
||||
// 迁移到 runtime.CallAPITyped 后,非零 code 的业务错误由 errclass.BuildAPIError
|
||||
// 归类为 typed errs.* error(wire type 为 "api" 类别,不再是 legacy 的
|
||||
// *output.ExitError / "api_error"),但仍保留 code 与 message。与 drive/okr 等
|
||||
// 已迁移域一致:用 errs.ProblemOf 读 typed envelope,断言不弱化。
|
||||
func TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/tables",
|
||||
Body: map[string]interface{}{
|
||||
"code": 500002511,
|
||||
"msg": "k_dl_1600000:Invalid DB Branch:dev",
|
||||
},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Fatalf("error.type = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Code != 500002511 {
|
||||
t.Fatalf("error.code = %d, want 500002511", p.Code)
|
||||
}
|
||||
if !strings.Contains(p.Message, "Invalid DB Branch") {
|
||||
t.Fatalf("error.message missing 'Invalid DB Branch': %q", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableList_SuccessReturnsItemsWithStats(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/tables",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "orders",
|
||||
"description": "订单表",
|
||||
"columns": []interface{}{map[string]interface{}{"name": "id"}, map[string]interface{}{"name": "user_id"}},
|
||||
"estimated_row_count": 1200,
|
||||
"size_bytes": 81920,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"name": "orders"`) {
|
||||
t.Fatalf("stdout missing table name: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"estimated_row_count": 1200`) {
|
||||
t.Fatalf("stdout missing estimated_row_count: %s", got)
|
||||
}
|
||||
// CLI 裁剪:json 默认不透出每表 columns[],折算成 column_count(mock 给了 2 列)。
|
||||
if !strings.Contains(got, `"column_count": 2`) {
|
||||
t.Fatalf("stdout missing column_count (should replace columns[]): %s", got)
|
||||
}
|
||||
if strings.Contains(got, `"columns"`) {
|
||||
t.Fatalf("stdout should NOT contain raw columns[] (stripped to column_count): %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// pretty 5 列 + 列名 (size / columns,不是 size_bytes / column_count) + size 友好格式(KB) +
|
||||
// 空 description 用 "—" 占位。
|
||||
func TestAppsDBTableList_PrettyRendersFiveColumnsHumanReadable(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/tables",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "orders",
|
||||
"description": "Order entries",
|
||||
"columns": []interface{}{map[string]interface{}{"name": "id"}, map[string]interface{}{"name": "user_id"}},
|
||||
"estimated_row_count": 1200,
|
||||
"size_bytes": 81920, // 80 KB
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "customers",
|
||||
"description": "",
|
||||
"columns": []interface{}{map[string]interface{}{"name": "id"}},
|
||||
"estimated_row_count": 350,
|
||||
"size_bytes": 24576, // 24 KB
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// Header 行 5 列命名。
|
||||
wantHeader := "name description estimated_row_count size columns"
|
||||
// rows
|
||||
wantOrders := "orders Order entries 1200 80 KB 2"
|
||||
wantCustomers := "customers — 350 24 KB 1"
|
||||
for _, want := range []string{wantHeader, wantOrders, wantCustomers} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing line %q\nactual output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// 禁止出现旧列名 / 原始字节。
|
||||
for _, banned := range []string{"size_bytes", "column_count", "81920", "24576"} {
|
||||
if strings.Contains(got, banned) {
|
||||
t.Errorf("pretty output contains %q (must be human-formatted)\noutput:\n%s", banned, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableList_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", " ", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "app-id") {
|
||||
t.Fatalf("expected app-id required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev",
|
||||
"--page-size", "50", "--page-token", "cursor-abc",
|
||||
"--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.API[0].Method != "GET" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/tables" {
|
||||
t.Fatalf("dry-run method/url = %s %s", env.API[0].Method, env.API[0].URL)
|
||||
}
|
||||
if env.API[0].Params["env"] != "dev" {
|
||||
t.Fatalf("dry-run params.env = %v (want dev)", env.API[0].Params["env"])
|
||||
}
|
||||
if pz, _ := env.API[0].Params["page_size"].(float64); int(pz) != 50 {
|
||||
t.Fatalf("dry-run params.page_size = %v (want 50)", env.API[0].Params["page_size"])
|
||||
}
|
||||
if env.API[0].Params["page_token"] != "cursor-abc" {
|
||||
t.Fatalf("dry-run params.page_token = %v (want cursor-abc)", env.API[0].Params["page_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, ok := env.API[0].Params["include_stats"]; ok {
|
||||
t.Fatalf("CLI should not send include_stats query, but got params=%v", env.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableList_RejectsBadEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--env", "prod", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "env") {
|
||||
t.Fatalf("expected env enum rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericAsFloat_AllTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in interface{}
|
||||
want float64
|
||||
ok bool
|
||||
}{
|
||||
{"float64", float64(3.5), 3.5, true},
|
||||
{"float32", float32(2), 2, true},
|
||||
{"int", int(7), 7, true},
|
||||
{"int32", int32(8), 8, true},
|
||||
{"int64", int64(9), 9, true},
|
||||
{"uint", uint(10), 10, true},
|
||||
{"uint32", uint32(11), 11, true},
|
||||
{"uint64", uint64(12), 12, true},
|
||||
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
||||
{"json.Number invalid", json.Number("abc"), 0, false},
|
||||
{"nil", nil, 0, false},
|
||||
{"unsupported string", "x", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, ok := numericAsFloat(c.in)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Fatalf("numericAsFloat(%v) = %v,%v want %v,%v", c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFloat_IntegerVsFractional(t *testing.T) {
|
||||
cases := []struct {
|
||||
in float64
|
||||
want string
|
||||
}{
|
||||
{24, "24"},
|
||||
{1.5, "1.5"},
|
||||
{2.04, "2.0"},
|
||||
{0, "0"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := formatFloat(c.in); got != c.want {
|
||||
t.Errorf("formatFloat(%v)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanBytes_UnitBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in interface{}
|
||||
want string
|
||||
}{
|
||||
{"non-numeric", "x", "—"},
|
||||
{"bytes", float64(512), "512 B"},
|
||||
{"kb", float64(2048), "2 KB"},
|
||||
{"mb fractional", float64(1572864), "1.5 MB"},
|
||||
{"gb integer", float64(2 * 1024 * 1024 * 1024), "2 GB"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := humanBytes(c.in); got != c.want {
|
||||
t.Errorf("humanBytes(%v)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntString_Cases(t *testing.T) {
|
||||
if got := intString(float64(42)); got != "42" {
|
||||
t.Errorf("intString(42)=%q want 42", got)
|
||||
}
|
||||
if got := intString("x"); got != "—" {
|
||||
t.Errorf("intString(non-numeric)=%q want —", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveColumnCount_Cases(t *testing.T) {
|
||||
if got := deriveColumnCount(map[string]interface{}{"columns": []interface{}{1, 2, 3}}); got != 3 {
|
||||
t.Errorf("deriveColumnCount=%d want 3", got)
|
||||
}
|
||||
if got := deriveColumnCount(map[string]interface{}{}); got != 0 {
|
||||
t.Errorf("deriveColumnCount(missing)=%d want 0", got)
|
||||
}
|
||||
if got := deriveColumnCount(map[string]interface{}{"columns": "notarray"}); got != 0 {
|
||||
t.Errorf("deriveColumnCount(wrongtype)=%d want 0", got)
|
||||
}
|
||||
}
|
||||
380
shortcuts/apps/apps_env_pull.go
Normal file
380
shortcuts/apps/apps_env_pull.go
Normal file
@@ -0,0 +1,380 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// envKeyPattern matches valid environment variable names: [A-Za-z_][A-Za-z0-9_]*
|
||||
var envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
type envPullDatabaseInfo struct {
|
||||
Detected bool
|
||||
ExpiresAtRaw string
|
||||
ExpiresAtText string
|
||||
}
|
||||
|
||||
// AppsEnvPull pulls startup env vars for an app into the local .env.local file.
|
||||
var AppsEnvPull = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+env-pull",
|
||||
Description: "Pull app startup env vars into the local project .env.local",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +env-pull --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID"},
|
||||
{Name: "project-path", Desc: "local project root path (defaults to current directory)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: "--app-id is required"}, Param: "app-id"}
|
||||
}
|
||||
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
if err != nil {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
|
||||
}
|
||||
if err := checkEnvPullTarget(envFile); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Pull app startup env vars into the local .env.local file").
|
||||
Set("project_path", projectPath).
|
||||
Set("env_file", envFile)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
if err != nil {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
|
||||
}
|
||||
if err := checkEnvPullTarget(envFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rctx.EnsureScopes([]string{"spark:app:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||
}
|
||||
|
||||
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if envVars == nil {
|
||||
envVars = map[string]string{}
|
||||
}
|
||||
envVars["FORCE_DB_BRANCH"] = "dev"
|
||||
original, err := readEnvPullFile(envFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged, updated, created := mergeEnvPullFileContent(original, envVars)
|
||||
if err := ensureEnvPullParentDir(envFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.AtomicWrite(envFile, []byte(merged), 0o600); err != nil {
|
||||
return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot write %s: %v", envFile, err)}, Cause: err}
|
||||
}
|
||||
|
||||
result := buildEnvPullSuccessData(appID, envFile, databaseInfo)
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
writeEnvPullPretty(w, appID, envFile, databaseInfo, skippedKeys)
|
||||
})
|
||||
_ = updated
|
||||
_ = created
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
||||
if strings.TrimSpace(projectPath) == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
projectPath = cwd
|
||||
}
|
||||
if err := validate.RejectControlChars(projectPath, "--project-path"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
projectPath = filepath.Clean(projectPath)
|
||||
return projectPath, filepath.Join(projectPath, ".env.local"), nil
|
||||
}
|
||||
|
||||
func checkEnvPullTarget(envFile string) error {
|
||||
info, err := os.Lstat(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; direct lstat is needed to reject symlinks before write.
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("cannot inspect %s: %v", envFile, err)}, Param: "project-path", Cause: err}
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file, not a symlink", envFile)}, Param: "project-path"}
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file", envFile)}, Param: "project-path"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPullDatabaseInfo, []string, error) {
|
||||
raw := data["env_vars"]
|
||||
if raw == nil {
|
||||
if nested, ok := data["data"].(map[string]interface{}); ok {
|
||||
raw = nested["env_vars"]
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
|
||||
}
|
||||
|
||||
var skippedKeys []string
|
||||
switch typed := raw.(type) {
|
||||
case map[string]interface{}:
|
||||
out := make(map[string]string, len(typed))
|
||||
for key, value := range typed {
|
||||
if !envKeyPattern.MatchString(key) {
|
||||
skippedKeys = append(skippedKeys, key)
|
||||
continue
|
||||
}
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[key] = s
|
||||
}
|
||||
return out, envPullDatabaseInfo{Detected: hasEnvPullDatabase(out)}, skippedKeys, nil
|
||||
case []interface{}:
|
||||
out := make(map[string]string, len(typed))
|
||||
info := envPullDatabaseInfo{}
|
||||
for _, item := range typed {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, ok := entry["key"].(string)
|
||||
if !ok || strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
if !envKeyPattern.MatchString(key) {
|
||||
skippedKeys = append(skippedKeys, key)
|
||||
continue
|
||||
}
|
||||
value, ok := entry["value"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
if key == "SUDA_DATABASE_URL" {
|
||||
info.Detected = true
|
||||
info.ExpiresAtRaw, info.ExpiresAtText = extractEnvPullDatabaseExpiry(entry["extras"])
|
||||
}
|
||||
}
|
||||
return out, info, skippedKeys, nil
|
||||
default:
|
||||
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
|
||||
}
|
||||
}
|
||||
|
||||
func readEnvPullFile(envFile string) (string, error) {
|
||||
data, err := os.ReadFile(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; validated local file read for a single env file.
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot read %s: %v", envFile, err)}, Cause: err}
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func ensureEnvPullParentDir(envFile string) error {
|
||||
dir := filepath.Dir(envFile)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local mkdir for target env parent dir.
|
||||
return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot create %s: %v", dir, err)}, Cause: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeEnvPullFileContent(original string, envVars map[string]string) (string, []string, []string) {
|
||||
if len(envVars) == 0 {
|
||||
if original == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
return ensureTrailingNewline(original), nil, nil
|
||||
}
|
||||
|
||||
normalized := strings.ReplaceAll(original, "\r\n", "\n")
|
||||
lines := []string{}
|
||||
if normalized != "" {
|
||||
lines = strings.Split(normalized, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
}
|
||||
|
||||
used := make(map[string]bool, len(envVars))
|
||||
updated := make([]string, 0, len(envVars))
|
||||
for i, line := range lines {
|
||||
key, ok := parseEnvPullAssignmentLine(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value, exists := envVars[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
lines[i] = formatEnvPullAssignment(key, value)
|
||||
updated = append(updated, key)
|
||||
used[key] = true
|
||||
}
|
||||
|
||||
created := make([]string, 0, len(envVars))
|
||||
pending := make([]string, 0, len(envVars))
|
||||
for key := range envVars {
|
||||
if used[key] {
|
||||
continue
|
||||
}
|
||||
pending = append(pending, key)
|
||||
}
|
||||
sort.Strings(pending)
|
||||
for _, key := range pending {
|
||||
lines = append(lines, formatEnvPullAssignment(key, envVars[key]))
|
||||
created = append(created, key)
|
||||
}
|
||||
|
||||
sort.Strings(updated)
|
||||
content := strings.Join(lines, "\n")
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
return content, updated, created
|
||||
}
|
||||
|
||||
func parseEnvPullAssignmentLine(line string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "export ") || strings.HasPrefix(trimmed, "export\t") {
|
||||
remainder := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(trimmed, "export "), "export\t"))
|
||||
if remainder == "" || strings.HasPrefix(remainder, "=") {
|
||||
return "", false
|
||||
}
|
||||
trimmed = remainder
|
||||
}
|
||||
idx := strings.Index(trimmed, "=")
|
||||
if idx <= 0 {
|
||||
return "", false
|
||||
}
|
||||
key := strings.TrimSpace(trimmed[:idx])
|
||||
if key == "" || strings.ContainsAny(key, " \t") {
|
||||
return "", false
|
||||
}
|
||||
return key, true
|
||||
}
|
||||
|
||||
func formatEnvPullAssignment(key, value string) string {
|
||||
return fmt.Sprintf("%s=%s", key, strconv.Quote(value))
|
||||
}
|
||||
|
||||
func buildEnvPullSuccessData(appID, envFile string, databaseInfo envPullDatabaseInfo) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"env_file": envFile,
|
||||
}
|
||||
if databaseInfo.ExpiresAtRaw != "" {
|
||||
result["database_url_expires_at"] = databaseInfo.ExpiresAtRaw
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hasEnvPullDatabase(envVars map[string]string) bool {
|
||||
_, ok := envVars["SUDA_DATABASE_URL"]
|
||||
return ok
|
||||
}
|
||||
|
||||
func extractEnvPullDatabaseExpiry(rawExtras interface{}) (string, string) {
|
||||
extras, ok := rawExtras.([]interface{})
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
for _, raw := range extras {
|
||||
entry, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, _ := entry["key"].(string)
|
||||
if key != "expiresAt" {
|
||||
continue
|
||||
}
|
||||
switch value := entry["value"].(type) {
|
||||
case string:
|
||||
rawValue := strings.TrimSpace(value)
|
||||
ts, err := strconv.ParseInt(rawValue, 10, 64)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
|
||||
case float64:
|
||||
ts := int64(value)
|
||||
rawValue := strconv.FormatInt(ts, 10)
|
||||
return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func writeEnvPullPretty(w io.Writer, appID, envFile string, databaseInfo envPullDatabaseInfo, skippedKeys []string) {
|
||||
fmt.Fprintf(w, "✓ App detected: %s\n", appID)
|
||||
if databaseInfo.Detected {
|
||||
fmt.Fprintln(w, "✓ Development database detected")
|
||||
}
|
||||
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
|
||||
if databaseInfo.ExpiresAtText != "" {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "DATABASE_URL is valid until %s.\n", databaseInfo.ExpiresAtText)
|
||||
}
|
||||
if len(skippedKeys) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "⚠ Skipped %d invalid key(s): %s (key names must match [A-Za-z_][A-Za-z0-9_]*)\n", len(skippedKeys), strings.Join(skippedKeys, ", "))
|
||||
}
|
||||
fmt.Fprintf(w, "Run `lark-cli apps +env-pull --app-id <app_id>` again to refresh it.\n")
|
||||
}
|
||||
|
||||
func ensureTrailingNewline(s string) string {
|
||||
if s == "" || strings.HasSuffix(s, "\n") {
|
||||
return s
|
||||
}
|
||||
return s + "\n"
|
||||
}
|
||||
1081
shortcuts/apps/apps_env_pull_test.go
Normal file
1081
shortcuts/apps/apps_env_pull_test.go
Normal file
File diff suppressed because it is too large
Load Diff
52
shortcuts/apps/apps_examples_test.go
Normal file
52
shortcuts/apps/apps_examples_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppsShortcutsHaveExamples(t *testing.T) {
|
||||
realAppID := regexp.MustCompile(`app_[a-z0-9]{6,}`)
|
||||
email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
|
||||
phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`)
|
||||
for _, s := range Shortcuts() {
|
||||
hasExample := false
|
||||
for _, tip := range s.Tips {
|
||||
if strings.HasPrefix(tip, "Example: lark-cli apps +") {
|
||||
hasExample = true
|
||||
}
|
||||
if realAppID.MatchString(tip) {
|
||||
t.Errorf("%s tip leaks real-looking app id (use <app_id>): %q", s.Command, tip)
|
||||
}
|
||||
if email.MatchString(tip) || phone.MatchString(tip) {
|
||||
t.Errorf("%s tip leaks PII: %q", s.Command, tip)
|
||||
}
|
||||
}
|
||||
if !hasExample {
|
||||
t.Errorf("%s has no \"Example: lark-cli apps +...\" tip", s.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) {
|
||||
want := map[string]int{"+chat": 2, "+access-scope-set": 2}
|
||||
for _, s := range Shortcuts() {
|
||||
min, ok := want[s.Command]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n := 0
|
||||
for _, tip := range s.Tips {
|
||||
if strings.HasPrefix(tip, "Example: lark-cli apps +") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n < min {
|
||||
t.Errorf("%s has %d Example tips, want >= %d", s.Command, n, min)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
shortcuts/apps/apps_hint_leak_test.go
Normal file
67
shortcuts/apps/apps_hint_leak_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAppsErrorHintsCarryNoSecretsOrPII guards the actionable error hints added
|
||||
// for the apps command-governance task. Those hints are inline string literals
|
||||
// spread across several files (apps_env_pull.go, apps_access_scope_set.go,
|
||||
// apps_access_scope_get.go, apps_init.go git-push path, and the
|
||||
// gitCredentialIssueHint const in git_credential.go). They are stable English
|
||||
// strings, so we assert the verbatim copies here: a real app_id, an email, or a
|
||||
// phone number must never appear in a hint. Placeholders like <app_id> are
|
||||
// expected and must NOT trip the real-app-id regex.
|
||||
func TestAppsErrorHintsCarryNoSecretsOrPII(t *testing.T) {
|
||||
// These are copied verbatim from the source. If a hint changes, copy the new
|
||||
// text here so this leak guard keeps tracking the real production string.
|
||||
hints := []string{
|
||||
// apps_env_pull.go:86 and apps_access_scope_get.go:50 (identical literals)
|
||||
"verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`",
|
||||
// apps_access_scope_set.go:74
|
||||
"verify --app-id is correct; for scope=specific, each --targets id must be a valid open_id/department_id/chat_id and --approver a valid open_id; review the current scope with `lark-cli apps +access-scope-get --app-id <app_id>`",
|
||||
// apps_init.go:483 (git push rejection)
|
||||
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded",
|
||||
// git_credential.go gitCredentialIssueHint const (referenced directly so a
|
||||
// rename or text change breaks the build instead of silently drifting)
|
||||
gitCredentialIssueHint,
|
||||
// command-governance hints added for this task (referenced by const, no drift)
|
||||
appIDListHint,
|
||||
sessionStopHint,
|
||||
createHint,
|
||||
dbEnvCreateHint,
|
||||
dbTableGetHint,
|
||||
dbTableListHint,
|
||||
}
|
||||
|
||||
realAppID := regexp.MustCompile(`app_[a-z0-9]{6,}`)
|
||||
email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
|
||||
phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`)
|
||||
// An obvious secret: a PAT-like token or a "secret=..." / "token=..." pair.
|
||||
secret := regexp.MustCompile(`(?i)(pat-[a-z0-9]+|secret\s*[=:]\s*\S|token\s*[=:]\s*\S)`)
|
||||
|
||||
for _, h := range hints {
|
||||
if realAppID.MatchString(h) {
|
||||
t.Errorf("hint leaks a real-looking app id (use <app_id>): %q", h)
|
||||
}
|
||||
if email.MatchString(h) {
|
||||
t.Errorf("hint leaks an email address: %q", h)
|
||||
}
|
||||
if phone.MatchString(h) {
|
||||
t.Errorf("hint leaks a phone number: %q", h)
|
||||
}
|
||||
if secret.MatchString(h) {
|
||||
t.Errorf("hint leaks an obvious secret/token: %q", h)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the placeholder <app_id> must NOT match the real-app-id regex,
|
||||
// otherwise the guard above would be a false positive on legitimate hints.
|
||||
if realAppID.MatchString("<app_id>") {
|
||||
t.Fatal("realAppID regex incorrectly matches the <app_id> placeholder")
|
||||
}
|
||||
}
|
||||
129
shortcuts/apps/apps_hints_more_test.go
Normal file
129
shortcuts/apps/apps_hints_more_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func assertHintContains(t *testing.T, sc common.Shortcut, args []string, stub *httpmock.Stub, want string) {
|
||||
t.Helper()
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(stub)
|
||||
err := runAppsShortcut(t, sc, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Fatalf("hint %q does not contain %q", p.Hint, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionCreate_4xxFailureCarriesListHint(t *testing.T) {
|
||||
assertHintContains(t, AppsSessionCreate,
|
||||
[]string{"+session-create", "--app-id", "app_x", "--as", "user"},
|
||||
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
||||
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "app not found"}},
|
||||
"apps +list")
|
||||
}
|
||||
|
||||
func TestAppsSessionList_4xxFailureCarriesListHint(t *testing.T) {
|
||||
assertHintContains(t, AppsSessionList,
|
||||
[]string{"+session-list", "--app-id", "app_x", "--as", "user"},
|
||||
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
||||
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
|
||||
"apps +list")
|
||||
}
|
||||
|
||||
func TestAppsUpdate_4xxFailureCarriesListHint(t *testing.T) {
|
||||
assertHintContains(t, AppsUpdate,
|
||||
[]string{"+update", "--app-id", "app_x", "--name", "n", "--as", "user"},
|
||||
&httpmock.Stub{Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x",
|
||||
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "app not found"}},
|
||||
"apps +list")
|
||||
}
|
||||
|
||||
func TestAppsReleaseList_4xxFailureCarriesListHint(t *testing.T) {
|
||||
assertHintContains(t, AppsReleaseList,
|
||||
[]string{"+release-list", "--app-id", "app_x", "--as", "user"},
|
||||
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases",
|
||||
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
|
||||
"apps +list")
|
||||
}
|
||||
|
||||
func TestAppsSessionStop_4xxFailureCarriesSessionHint(t *testing.T) {
|
||||
assertHintContains(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "s1", "--turn-id", "t1", "--as", "user"},
|
||||
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/sessions/s1/stop",
|
||||
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "session not found"}},
|
||||
"+session-list")
|
||||
}
|
||||
|
||||
func TestAppsCreate_4xxFailureCarriesTypeHint(t *testing.T) {
|
||||
assertHintContains(t, AppsCreate,
|
||||
[]string{"+create", "--name", "n", "--app-type", "html", "--as", "user"},
|
||||
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps",
|
||||
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
|
||||
"full_stack")
|
||||
}
|
||||
|
||||
func TestAppsDBEnvCreate_4xxFailureCarriesHint(t *testing.T) {
|
||||
assertHintContains(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"},
|
||||
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
|
||||
Status: http.StatusConflict, Body: map[string]interface{}{"msg": "already multi-env"}},
|
||||
"+db-table-list")
|
||||
}
|
||||
|
||||
func TestAppsDBTableGet_4xxFailureCarriesHint(t *testing.T) {
|
||||
assertHintContains(t, AppsDBTableGet,
|
||||
[]string{"+db-table-get", "--app-id", "app_x", "--table", "users", "--as", "user"},
|
||||
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables/users",
|
||||
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "table not found"}},
|
||||
"+db-table-list")
|
||||
}
|
||||
|
||||
func TestAppsDBTableList_4xxFailureCarriesHint(t *testing.T) {
|
||||
assertHintContains(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"},
|
||||
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables",
|
||||
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "dev env not found"}},
|
||||
"+db-env-create")
|
||||
}
|
||||
|
||||
// withAppsHint must only fill an EMPTY hint; an upstream-provided hint wins.
|
||||
func TestWithAppsHint_DoesNotOverrideUpstreamHint(t *testing.T) {
|
||||
upstream := &errs.Problem{Message: "boom", Hint: "upstream specific hint"}
|
||||
got := withAppsHint(upstream, appIDListHint)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", got)
|
||||
}
|
||||
if p.Hint != "upstream specific hint" {
|
||||
t.Fatalf("upstream hint was overridden: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// withAppsHint fills the hint when empty and leaves Message untouched.
|
||||
func TestWithAppsHint_FillsEmptyHintKeepsMessage(t *testing.T) {
|
||||
p0 := &errs.Problem{Message: "boom"}
|
||||
got := withAppsHint(p0, appIDListHint)
|
||||
p, _ := errs.ProblemOf(got)
|
||||
if p.Hint != appIDListHint {
|
||||
t.Fatalf("hint not filled: %q", p.Hint)
|
||||
}
|
||||
if p.Message != "boom" {
|
||||
t.Fatalf("message mutated: %q", p.Message)
|
||||
}
|
||||
}
|
||||
91
shortcuts/apps/apps_hints_test.go
Normal file
91
shortcuts/apps/apps_hints_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAppsEnvPull_4xxFailureCarriesListHint verifies that a 4xx failure from the
|
||||
// env_vars endpoint surfaces an actionable hint pointing at `lark-cli apps +list`.
|
||||
func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
Status: http.StatusForbidden,
|
||||
Body: map[string]interface{}{"msg": "permission denied"},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsEnvPull,
|
||||
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "apps +list") {
|
||||
t.Fatalf("hint missing `apps +list`: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsAccessScopeGet_4xxFailureCarriesListHint verifies the access-scope-get
|
||||
// 4xx failure points at `lark-cli apps +list`.
|
||||
func TestAppsAccessScopeGet_4xxFailureCarriesListHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
|
||||
Status: http.StatusNotFound,
|
||||
Body: map[string]interface{}{"msg": "app not found"},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsAccessScopeGet,
|
||||
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "apps +list") {
|
||||
t.Fatalf("hint missing `apps +list`: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsAccessScopeSet_4xxFailureCarriesScopeGetHint verifies the
|
||||
// access-scope-set 4xx failure points at `+access-scope-get`.
|
||||
func TestAppsAccessScopeSet_4xxFailureCarriesScopeGetHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
|
||||
Status: http.StatusBadRequest,
|
||||
Body: map[string]interface{}{"msg": "invalid target id"},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsAccessScopeSet,
|
||||
[]string{"+access-scope-set", "--app-id", "app_x", "--scope", "tenant", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+access-scope-get") {
|
||||
t.Fatalf("hint missing `+access-scope-get`: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,13 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
Command: "+html-publish",
|
||||
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
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},
|
||||
|
||||
674
shortcuts/apps/apps_init.go
Normal file
674
shortcuts/apps/apps_init.go
Normal file
@@ -0,0 +1,674 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// defaultInitBranch is the fixed remote branch +init checks out after clone.
|
||||
const defaultInitBranch = "sprint/default"
|
||||
|
||||
// Fixed init commit subjects. Constants — never interpolate user input. The
|
||||
// empty-repo (`app init`) path splits the scaffolded tree into two commits;
|
||||
// the non-empty (`app sync`) path stays a single commit.
|
||||
const (
|
||||
commitMsgAppCode = "chore: initialize app project code"
|
||||
commitMsgAppConfig = "chore: initialize miaoda app config"
|
||||
commitMsgUpgrade = "chore: initialize miaoda app repository"
|
||||
)
|
||||
|
||||
// scaffold kinds returned by runScaffold and consumed by commitAndPushIfDirty.
|
||||
const (
|
||||
scaffoldKindInit = "init"
|
||||
scaffoldKindUpgrade = "upgrade"
|
||||
)
|
||||
|
||||
const (
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
|
||||
defaultTemplate = "nestjs-react-fullstack"
|
||||
metaRelPath = ".spark/meta.json"
|
||||
steeringRelPath = ".agent/skills/steering"
|
||||
seedReadme = "README.md"
|
||||
)
|
||||
|
||||
// initRunner is the commandRunner used by +init. Package-level so unit tests
|
||||
// can swap in a fakeCommandRunner. Production uses execCommandRunner.
|
||||
var initRunner commandRunner = execCommandRunner{}
|
||||
|
||||
// AppsInit initializes a Miaoda app's code and local development environment.
|
||||
var AppsInit = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+init",
|
||||
Description: "Initialize a Miaoda app's code and local development environment",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +init --app-id <app_id> --dir <dir>",
|
||||
"Example: lark-cli apps +init --app-id <app_id> --dir <dir> --dry-run",
|
||||
},
|
||||
// +init makes no direct lark API calls (it shells out to the
|
||||
// +git-credential-init subprocess, which enforces its own scopes), so it
|
||||
// declares no scopes of its own. Explicit []string{} (not nil) per the
|
||||
// convention enforced by TestAllShortcutsScopesNotNil.
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
// NOTE: --app-id is intentionally NOT Required:true. The framework maps
|
||||
// Required:true to cobra's MarkFlagRequired, whose error is plain-text
|
||||
// exit-1 (root.go handleRootError case 4), bypassing the structured
|
||||
// envelope. The spec and the E2E assert exit-2 + a structured
|
||||
// {"ok":false,"error":{...}} envelope for missing --app-id, so the empty
|
||||
// check lives in Validate (output.ErrValidation -> ExitValidation=2).
|
||||
{Name: "app-id", Desc: "Miaoda app ID"},
|
||||
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
|
||||
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
template := resolveTemplate(rctx, appID)
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Initialize Miaoda app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
|
||||
Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
|
||||
Set("checkout", "git checkout "+defaultInitBranch).
|
||||
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
|
||||
Set("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes").
|
||||
Set("template", template).
|
||||
Set("env_pull", fmt.Sprintf("apps +env-pull --app-id %s --project-path <clone_path> --format json (after successful init)", appID))
|
||||
dir, err := resolveTargetPath(rctx, appID)
|
||||
if err != nil {
|
||||
dry.Set("dir_error", err.Error())
|
||||
dir = defaultCloneDir(appID)
|
||||
} else if isAlreadyInitialized(dir) {
|
||||
dry.Set("already_initialized", true)
|
||||
} else if e := ensureEmptyDir(dir); e != nil {
|
||||
dry.Set("dir_error", e.Error())
|
||||
}
|
||||
dry.Set("clone", fmt.Sprintf("git clone -- <repository_url-from-credential-init> %s", dir))
|
||||
dry.Set("clone_path", dir)
|
||||
return dry
|
||||
},
|
||||
Execute: appsInitExecute,
|
||||
}
|
||||
|
||||
// defaultCloneDir returns the default clone target (./<app-id>) for an app ID.
|
||||
func defaultCloneDir(appID string) string {
|
||||
return filepath.Join(".", appID)
|
||||
}
|
||||
|
||||
// resolveTemplate returns the scaffold template for an empty-repo `app init`.
|
||||
// An explicit --template wins. When omitted, it should be derived from the
|
||||
// app's tech stack.
|
||||
// TODO(apps-init): look up the app by appID via the apps API (e.g. `apps +list`
|
||||
// or a get-app endpoint), read its tech stack, and map tech-stack -> template
|
||||
// through a (future) enum. Until that lands, fall back to defaultTemplate.
|
||||
func resolveTemplate(rctx *common.RuntimeContext, appID string) string {
|
||||
if t := strings.TrimSpace(rctx.Str("template")); t != "" {
|
||||
return t
|
||||
}
|
||||
// TODO(apps-init): derive from app tech stack (apps API + enum mapping).
|
||||
return defaultTemplate
|
||||
}
|
||||
|
||||
// initLogf writes a one-line progress message to stderr. stdout stays reserved
|
||||
// for the structured JSON envelope, so progress never pollutes it. Callers must
|
||||
// never pass a raw repository_url (it may embed a token) — pass step names,
|
||||
// clone_path, branch, or scaffold kind, and route any URL through
|
||||
// redactURLCredentials first.
|
||||
func initLogf(rctx *common.RuntimeContext, format string, args ...interface{}) {
|
||||
fmt.Fprintf(rctx.IO().ErrOut, "→ "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// resolveTargetPath computes the absolute clone target from --dir (or the
|
||||
// ./<app-id> default). Unlike the prior SafeInputPath approach it does NOT
|
||||
// confine to cwd — the clone destination is user-chosen (the skill prompts for
|
||||
// it). It rejects empty input and control characters; symlink/no-clobber
|
||||
// guarding happens in ensureEmptyDir.
|
||||
func resolveTargetPath(rctx *common.RuntimeContext, appID string) (string, error) {
|
||||
raw := strings.TrimSpace(rctx.Str("dir"))
|
||||
if raw == "" {
|
||||
raw = defaultCloneDir(appID)
|
||||
}
|
||||
// Reject ALL control characters (incl. tab/newline — a newline in an echoed
|
||||
// path is a log-injection vector); charcheck additionally rejects dangerous
|
||||
// Unicode (bidi overrides, zero-width) that IsControl does not.
|
||||
if strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return "", output.ErrValidation("--dir must not contain control characters")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(raw, "--dir"); err != nil {
|
||||
return "", output.ErrValidation("%v", err)
|
||||
}
|
||||
abs, err := filepath.Abs(raw) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); raw is control-char-validated above, and FileIO.ResolvePath cannot resolve a clone target (it rejects absolute paths).
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("--dir cannot be resolved: %v", err)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// ensureEmptyDir refuses to clone into an existing non-empty dir, a symlink, or
|
||||
// a non-directory. A non-existent path is fine (git clone creates it). Uses
|
||||
// Lstat so a symlinked target is rejected rather than followed.
|
||||
func ensureEmptyDir(dir string) error {
|
||||
info, err := os.Lstat(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and lstat is required to reject a symlink (FileIO has no Lstat; its Stat follows symlinks).
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return output.ErrValidation("--dir cannot be read: %v", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return output.ErrValidation("--dir must not be a symlink: %q", dir)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--dir exists and is not a directory: %q", dir)
|
||||
}
|
||||
entries, err := os.ReadDir(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and FileIO has no ReadDir.
|
||||
if err != nil {
|
||||
return output.ErrValidation("--dir cannot be read: %v", err)
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
return output.ErrValidation("target directory %q already exists and is not empty", dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAlreadyInitialized reports whether dir is an already-initialized Miaoda app
|
||||
// repo, detected by the presence of <dir>/.spark/meta.json (regardless of its
|
||||
// app_id value). Used to short-circuit +init into a friendly no-op.
|
||||
func isAlreadyInitialized(dir string) bool {
|
||||
info, err := os.Stat(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths.
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
|
||||
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
|
||||
// the file does not exist, this is a no-op (we never create it).
|
||||
func ensureMetaAppID(dir, appID string) error {
|
||||
path := filepath.Join(dir, metaRelPath)
|
||||
b, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "read %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "parse %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
if cur, _ := m["app_id"].(string); strings.TrimSpace(cur) != "" {
|
||||
return nil
|
||||
}
|
||||
if m == nil {
|
||||
m = map[string]interface{}{}
|
||||
}
|
||||
m["app_id"] = appID
|
||||
out, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "marshal %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Save rejects absolute paths.
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "write %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasSteeringSkills reports whether <dir>/.agent/skills/steering exists as a dir.
|
||||
func hasSteeringSkills(dir string) bool {
|
||||
info, err := os.Stat(filepath.Join(dir, steeringRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths.
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// isEmptyRepo reports whether the checked-out branch has no tracked files
|
||||
// other than the backend's default seed README.md. `git ls-files` listing
|
||||
// nothing — or only README.md — counts as empty (→ scaffold via `app init`).
|
||||
func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
|
||||
stdout, stderr, err := initRunner.Run(ctx, dir, "git", "ls-files")
|
||||
if err != nil {
|
||||
return false, output.Errorf(output.ExitAPI, "git_ls_files", "git ls-files failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
|
||||
f := strings.TrimSpace(line)
|
||||
// Match the seed exactly (case- and path-sensitive): only a root-level
|
||||
// "README.md" is the backend's default seed. A docs/README.md or readme.md
|
||||
// is treated as real content (→ non-empty), which is the safe direction
|
||||
// (skip scaffolding rather than risk overwriting). Extend this allow-list
|
||||
// here if the backend's seed set grows.
|
||||
if f == "" || f == seedReadme {
|
||||
continue
|
||||
}
|
||||
return false, nil // a non-README tracked file → non-empty repo
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// runScaffold runs the npx scaffolding step inside the cloned repo (cwd=dir).
|
||||
// Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch +
|
||||
// conditional `skills sync`. Returns "init" or "upgrade".
|
||||
func runScaffold(ctx context.Context, dir, appID, template string) (string, error) {
|
||||
empty, err := isEmptyRepo(ctx, dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if empty {
|
||||
// isEmptyRepo treats a repo with no tracked files — or only the backend's
|
||||
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
|
||||
// appear, extend isEmptyRepo's allow-list accordingly.
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_app_init", "npx app init failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
return scaffoldKindInit, nil
|
||||
}
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_app_sync", "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if err := ensureMetaAppID(dir, appID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasSteeringSkills(dir) {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_skills_sync", "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
}
|
||||
return scaffoldKindUpgrade, nil
|
||||
}
|
||||
|
||||
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
|
||||
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
|
||||
// matches the contract emitted by `apps +git-credential-init`.
|
||||
func parseRepoURLFromEnvelope(stdout string) (string, error) {
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "could not parse +git-credential-init output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init returned no repository_url")
|
||||
}
|
||||
return env.Data.RepositoryURL, nil
|
||||
}
|
||||
|
||||
// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success
|
||||
// envelope ({"ok":true,"data":{"env_file":"..."}}) on stdout.
|
||||
func parseEnvFileFromEnvelope(stdout string) (string, error) {
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
EnvFile string `json:"env_file"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "could not parse +env-pull output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.EnvFile) == "" {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull returned no env_file")
|
||||
}
|
||||
return env.Data.EnvFile, nil
|
||||
}
|
||||
|
||||
// parseEnvPullErrorEnvelope extracts a single-line reason from a `+env-pull`
|
||||
// error envelope ({"ok":false,"error":{"type":...,"message":...}}) on stderr.
|
||||
// Returns "" when stderr is not a parseable error envelope (caller falls back).
|
||||
func parseEnvPullErrorEnvelope(stderr string) string {
|
||||
var env struct {
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(stderr)), &env); err != nil {
|
||||
return ""
|
||||
}
|
||||
msg := strings.TrimSpace(env.Error.Message)
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
if t := strings.TrimSpace(env.Error.Type); t != "" {
|
||||
return t + ": " + msg
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// validateRepoURLScheme rejects any repository_url that is not http(s):// to
|
||||
// block git's dangerous transports (ext::, file://, ssh://) and option injection.
|
||||
func validateRepoURLScheme(repoURL string) error {
|
||||
if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") {
|
||||
return nil
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "validation",
|
||||
"repository_url from +git-credential-init must be http(s); refusing %q", redactURLCredentials(repoURL))
|
||||
}
|
||||
|
||||
func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
|
||||
dir, err := resolveTargetPath(rctx, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
// initialized Miaoda app repo -> skip clone/scaffold/commit, but still refresh
|
||||
// the local env so a re-run picks up the latest startup env vars.
|
||||
if isAlreadyInitialized(dir) {
|
||||
initLogf(rctx, "Already initialized at %s — refreshing local environment", dir)
|
||||
out := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"clone_path": dir,
|
||||
"scaffold": "already_initialized",
|
||||
"committed": false,
|
||||
"pushed": false,
|
||||
}
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
out["env_pulled"] = envPulled
|
||||
if envPulled {
|
||||
initLogf(rctx, "Local environment written to %s", envFile)
|
||||
out["env_file"] = envFile
|
||||
out["message"] = "Repository already initialized. Local env refreshed — you can start developing."
|
||||
} else {
|
||||
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
|
||||
out["env_pull_error"] = envPullErr
|
||||
out["message"] = fmt.Sprintf("Repository already initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Already initialized at %s\n", dir)
|
||||
if envPulled {
|
||||
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
|
||||
} else {
|
||||
fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr)
|
||||
fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID)
|
||||
}
|
||||
fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "dependency",
|
||||
"git executable not found on PATH", "install git and ensure it is on your PATH")
|
||||
}
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "dependency",
|
||||
"npx executable not found on PATH", "install Node.js (which provides npx) and ensure it is on your PATH")
|
||||
}
|
||||
|
||||
if err := ensureEmptyDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initLogf(rctx, "Issuing repository credentials for %s...", appID)
|
||||
repoURL, err := issueCredentials(ctx, rctx, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRepoURLScheme(repoURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initLogf(rctx, "Cloning into %s...", dir)
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_clone", "git clone failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
initLogf(rctx, "Checking out %s...", defaultInitBranch)
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "git", "checkout", defaultInitBranch); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_checkout", "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
}
|
||||
|
||||
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
|
||||
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
committed, pushed, err := commitAndPushIfDirty(ctx, dir, scaffold)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pushed {
|
||||
initLogf(rctx, "Committed and pushed to %s", defaultInitBranch)
|
||||
} else {
|
||||
initLogf(rctx, "Working tree clean — skipped commit/push")
|
||||
}
|
||||
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
if envPulled {
|
||||
initLogf(rctx, "Local environment written to %s", envFile)
|
||||
} else {
|
||||
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"repository_url": redactURLCredentials(repoURL),
|
||||
"branch": defaultInitBranch,
|
||||
"clone_path": dir,
|
||||
"scaffold": scaffold,
|
||||
"committed": committed,
|
||||
"pushed": pushed,
|
||||
"env_pulled": envPulled,
|
||||
"message": "Repository initialized. You can start developing.",
|
||||
}
|
||||
if envPulled {
|
||||
out["env_file"] = envFile
|
||||
} else {
|
||||
out["env_pull_error"] = envPullErr
|
||||
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir)
|
||||
fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold)
|
||||
if envPulled {
|
||||
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
|
||||
} else {
|
||||
fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr)
|
||||
fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID)
|
||||
}
|
||||
fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// pullEnv runs `<self> apps +env-pull --app-id <appID> --project-path <dir>
|
||||
// --format json`, forwarding --as when set. Returns (envFile, "") on success or
|
||||
// ("", reason) on failure. Non-fatal by contract: the caller logs a warning and
|
||||
// continues. The success envelope is read from stdout, the error envelope from
|
||||
// stderr (lark-cli writes structured errors to stderr; see cmd/root.go
|
||||
// handleRootError). The reason is always redacted.
|
||||
func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string) (envFile, reason string) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", redactURLCredentials(fmt.Sprintf("cannot locate lark-cli executable: %v", err))
|
||||
}
|
||||
args := []string{"apps", "+env-pull", "--app-id", appID, "--project-path", dir, "--format", "json"}
|
||||
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
|
||||
args = append(args, "--as", as)
|
||||
}
|
||||
stdout, stderr, runErr := initRunner.Run(ctx, "", self, args...)
|
||||
if runErr != nil {
|
||||
r := parseEnvPullErrorEnvelope(stderr)
|
||||
if r == "" {
|
||||
r = gitErr(stderr, runErr)
|
||||
}
|
||||
return "", redactURLCredentials(r)
|
||||
}
|
||||
envFile, perr := parseEnvFileFromEnvelope(stdout)
|
||||
if perr != nil {
|
||||
return "", redactURLCredentials(perr.Error())
|
||||
}
|
||||
return envFile, ""
|
||||
}
|
||||
|
||||
// issueCredentials runs `<self> apps +git-credential-init --app-id <id> --format json`
|
||||
// and returns the repo_url it reports. Forwards --as when set.
|
||||
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "internal", "cannot locate lark-cli executable: %v", err)
|
||||
}
|
||||
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
|
||||
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
|
||||
args = append(args, "--as", as)
|
||||
}
|
||||
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
|
||||
if err != nil {
|
||||
return "", output.ErrWithHint(output.ExitAPI, "credential_init",
|
||||
fmt.Sprintf("apps +git-credential-init failed: %s", gitErr(stderr, err)),
|
||||
"ensure apps +git-credential-init is available and you are logged in")
|
||||
}
|
||||
return parseRepoURLFromEnvelope(stdout)
|
||||
}
|
||||
|
||||
// commitAndPushIfDirty commits and pushes only when the working tree has
|
||||
// changes; a clean tree is a no-op (returns false,false). For the empty-repo
|
||||
// init path (scaffoldKind == "init") it splits the scaffolded tree into two
|
||||
// commits — app project code, then Miaoda config (.spark/.agent) — skipping
|
||||
// either commit when that group has no changes (no empty commits). Other paths
|
||||
// commit once. Push is a single `git push origin <branch>` for all commits.
|
||||
func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) {
|
||||
status, stderr, runErr := initRunner.Run(ctx, dir, "git", "status", "--porcelain")
|
||||
if runErr != nil {
|
||||
return false, false, output.Errorf(output.ExitAPI, "git_status", "git status failed: %s", gitErr(stderr, runErr))
|
||||
}
|
||||
if strings.TrimSpace(status) == "" {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
if scaffoldKind == scaffoldKindInit {
|
||||
// Stage each group by its exact porcelain paths (never gitignored files),
|
||||
// so neither `git add` errors on an ignored path like .agent.
|
||||
appPaths, configPaths := classifyPorcelain(status)
|
||||
if len(appPaths) > 0 {
|
||||
if e := stageAndCommit(ctx, dir, commitMsgAppCode, appPaths...); e != nil {
|
||||
return committed, false, e
|
||||
}
|
||||
committed = true
|
||||
}
|
||||
if len(configPaths) > 0 {
|
||||
if e := stageAndCommit(ctx, dir, commitMsgAppConfig, configPaths...); e != nil {
|
||||
return committed, false, e
|
||||
}
|
||||
committed = true
|
||||
}
|
||||
} else {
|
||||
if e := stageAndCommit(ctx, dir, commitMsgUpgrade, "."); e != nil {
|
||||
return false, false, e
|
||||
}
|
||||
committed = true
|
||||
}
|
||||
|
||||
if !committed {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil {
|
||||
return true, false, withAppsHint(
|
||||
output.Errorf(output.ExitAPI, "git_push", "git push failed: %s", gitErr(se, e)),
|
||||
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded")
|
||||
}
|
||||
return true, true, nil
|
||||
}
|
||||
|
||||
// stageAndCommit stages the given pathspecs (`git add -A -- <pathspecs>`) and
|
||||
// makes one `git commit --no-verify -m message`. --no-verify skips the scaffold
|
||||
// repo's local pre-commit / commit-msg hooks (local only; the later push is not
|
||||
// --no-verify). Callers gate this on classifyPorcelain so the group is non-empty
|
||||
// and the commit never hits "nothing to commit".
|
||||
func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...string) error {
|
||||
addArgs := append([]string{"add", "-A", "--"}, pathspecs...)
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", addArgs...); e != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_add", "git add failed: %s", gitErr(se, e))
|
||||
}
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_commit", "git commit failed: %s", gitErr(se, e))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyPorcelain parses `git status --porcelain` output and partitions the
|
||||
// changed paths into the "app code" group (anything outside .spark/ and .agent/)
|
||||
// and the "Miaoda config" group (.spark/ and .agent/). It returns the exact
|
||||
// porcelain paths so callers can stage them verbatim: porcelain never lists
|
||||
// gitignored files, so `git add -- <these paths>` never trips git's ignored-path
|
||||
// error. (Naming an ignored dir explicitly — or combining a "." pathspec with
|
||||
// :(exclude) magic — DOES error when a scaffold template gitignores e.g. .agent,
|
||||
// which is why we stage exact paths instead of pathspecs.)
|
||||
func classifyPorcelain(status string) (appPaths, configPaths []string) {
|
||||
for _, line := range strings.Split(status, "\n") {
|
||||
p := porcelainPath(line)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if isConfigPath(p) {
|
||||
configPaths = append(configPaths, p)
|
||||
} else {
|
||||
appPaths = append(appPaths, p)
|
||||
}
|
||||
}
|
||||
return appPaths, configPaths
|
||||
}
|
||||
|
||||
// porcelainPath extracts the path from a `git status --porcelain` v1 line.
|
||||
// Format is "XY <path>" (2 status chars + space); rename/copy lines are
|
||||
// "XY <orig> -> <dest>" (dest is what matters). Quoted paths are unquoted.
|
||||
func porcelainPath(line string) string {
|
||||
if len(line) < 4 {
|
||||
return ""
|
||||
}
|
||||
p := line[3:]
|
||||
if i := strings.Index(p, " -> "); i >= 0 {
|
||||
p = p[i+len(" -> "):]
|
||||
}
|
||||
p = strings.TrimSpace(p)
|
||||
p = strings.Trim(p, `"`)
|
||||
return p
|
||||
}
|
||||
|
||||
// isConfigPath reports whether p is the Miaoda app-config group: the .spark or
|
||||
// .agent directory itself, or anything under them. ".sparkrc" is NOT config.
|
||||
func isConfigPath(p string) bool {
|
||||
return p == ".spark" || p == ".agent" ||
|
||||
strings.HasPrefix(p, ".spark/") || strings.HasPrefix(p, ".agent/")
|
||||
}
|
||||
|
||||
// gitErr builds a redacted, single-line error detail from stderr (falling back
|
||||
// to the exec error). Always redacts embedded credentials.
|
||||
func gitErr(stderr string, err error) string {
|
||||
s := strings.TrimSpace(stderr)
|
||||
if s == "" && err != nil {
|
||||
s = err.Error()
|
||||
}
|
||||
return redactURLCredentials(s)
|
||||
}
|
||||
1468
shortcuts/apps/apps_init_test.go
Normal file
1468
shortcuts/apps/apps_init_test.go
Normal file
File diff suppressed because it is too large
Load Diff
30
shortcuts/apps/apps_jq_tips_test.go
Normal file
30
shortcuts/apps/apps_jq_tips_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListyCommandsHaveJqTip(t *testing.T) {
|
||||
wantCmds := map[string]bool{
|
||||
"+list": true, "+db-table-list": true, "+db-table-schema": true,
|
||||
"+db-sql": true, "+release-list": true, "+session-list": true,
|
||||
}
|
||||
for _, s := range Shortcuts() {
|
||||
if !wantCmds[s.Command] {
|
||||
continue
|
||||
}
|
||||
has := false
|
||||
for _, tip := range s.Tips {
|
||||
if strings.Contains(tip, "--jq") || strings.Contains(tip, "-q '") {
|
||||
has = true
|
||||
}
|
||||
}
|
||||
if !has {
|
||||
t.Errorf("%s should have a --jq filter tip", s.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,23 +12,30 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsList lists Miaoda apps owned by the calling user (cursor pagination).
|
||||
// AppsList lists Miaoda apps visible to the calling user (cursor pagination).
|
||||
//
|
||||
// Hidden from --help / tab completion (Hidden: true) so agents do not discover it
|
||||
// as a way to enumerate / search applications. Direct invocation still works for
|
||||
// humans who know the command. When agents need an existing app_id, they should
|
||||
// ask the user to provide either the Miaoda app URL (extract app_id from the
|
||||
// path segment after /app/) or the app_id string directly; see lark-apps SKILL.md.
|
||||
// Supports name fuzzy match (--keyword), ownership-dimension filter
|
||||
// (--ownership: all / mine / shared), and app-type filter (--app-type). See
|
||||
// lark-apps SKILL.md for when an agent should use this to resolve an app_id
|
||||
// from a user-supplied name (only when the user named an app and a downstream
|
||||
// op needs its app_id — never unconditional enumeration).
|
||||
var AppsList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+list",
|
||||
Description: "List Miaoda apps owned by the calling user (cursor pagination)",
|
||||
Description: "List Miaoda apps visible to the calling user (cursor pagination)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Hidden: true,
|
||||
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"},
|
||||
},
|
||||
@@ -39,18 +46,42 @@ var AppsList = common.Shortcut{
|
||||
Params(buildAppsListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPI("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
|
||||
data, err := rctx.CallAPITyped("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
// Project away icon_url (an image URL agents can't render) and created_at
|
||||
// (redundant with updated_at) from every item BEFORE OutFormat, so json /
|
||||
// table / pretty are all lean. Every other field (description, etc.) is kept.
|
||||
rawItems, _ := data["items"].([]interface{})
|
||||
items := make([]interface{}, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
if k == "icon_url" || k == "created_at" {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
items = append(items, out)
|
||||
}
|
||||
data["items"] = items
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
// Table view (--format table) intentionally shows only the columns
|
||||
// most useful for visual scanning: app_id (to copy-paste downstream),
|
||||
// name (to match what the user sees in the UI), and updated_at (to
|
||||
// pick the most recent variant). description / icon_url / created_at
|
||||
// stay in the underlying JSON (--format json) but would make the
|
||||
// table too wide for a terminal.
|
||||
// Curated pretty view (--format pretty) shows the columns most useful
|
||||
// for visual scanning: app_id (to copy-paste downstream), name (to match
|
||||
// what the user sees in the UI), is_published / online_url (publish state
|
||||
// and post-publish access link — the actionable fields after a deploy),
|
||||
// and updated_at (to pick the most recent variant). online_url can be long
|
||||
// but is the key value once published; the renderer clamps column width.
|
||||
// Unpublished apps carry no online_url, so that cell renders empty.
|
||||
// description stays in the underlying data (--format json / table) but
|
||||
// would make the curated view too wide. icon_url / created_at are trimmed
|
||||
// from the data entirely above (not useful to an agent).
|
||||
rows := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
@@ -58,9 +89,11 @@ var AppsList = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"app_id": m["app_id"],
|
||||
"name": m["name"],
|
||||
"updated_at": m["updated_at"],
|
||||
"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)
|
||||
@@ -76,5 +109,14 @@ func buildAppsListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
if kw := strings.TrimSpace(rctx.Str("keyword")); kw != "" {
|
||||
params["keyword"] = kw
|
||||
}
|
||||
if ownership := strings.TrimSpace(rctx.Str("ownership")); ownership != "" {
|
||||
params["ownership"] = ownership
|
||||
}
|
||||
if at := strings.TrimSpace(rctx.Str("app-type")); at != "" {
|
||||
params["app_type"] = at
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -63,6 +63,56 @@ func TestAppsList_WithPageToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_WithKeywordOwnershipAppType(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps?app_type=html&keyword=%E9%97%AE%E5%8D%B7&ownership=mine&page_size=20",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--keyword", "问卷", "--ownership", "mine", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_InvalidOwnership(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--ownership", "bogus", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected enum validation error for --ownership bogus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_InvalidAppType(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--app-type", "HTML", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected enum validation error for --app-type HTML (hard cut to lowercase)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_DryRunWithFilters(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--keyword", "q", "--ownership", "all", "--app-type", "full_stack", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"keyword", "ownership", "app_type", "full_stack"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("dry-run missing %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
@@ -78,3 +128,86 @@ func TestAppsList_DryRun(t *testing.T) {
|
||||
t.Fatalf("dry-run missing page_size param: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_TrimsIconURLAndCreatedAt(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps?page_size=20",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"app_id": "app_x",
|
||||
"name": "Trim Me",
|
||||
"is_published": true,
|
||||
"online_url": "https://example.com/spark/faas/app_x",
|
||||
"updated_at": "2026-05-28T10:05:16Z",
|
||||
"created_at": "2026-05-01T08:00:00Z",
|
||||
"icon_url": "https://example.com/icon.png",
|
||||
"description": "An app to test trimming",
|
||||
},
|
||||
},
|
||||
"page_token": "next_cursor",
|
||||
"has_more": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsList, []string{"+list", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, drop := range []string{"icon_url", "created_at"} {
|
||||
if strings.Contains(got, drop) {
|
||||
t.Fatalf("default output should not contain %q:\n%s", drop, got)
|
||||
}
|
||||
}
|
||||
for _, keep := range []string{"app_id", "name", "is_published", "online_url", "updated_at", "description"} {
|
||||
if !strings.Contains(got, keep) {
|
||||
t.Fatalf("default output missing %q:\n%s", keep, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_PrettyShowsPublishFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps?page_size=20",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"app_id": "app_pub",
|
||||
"name": "Published App",
|
||||
"is_published": true,
|
||||
"online_url": "https://example.com/spark/faas/app_pub",
|
||||
"updated_at": "2026-05-28T10:05:16Z",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"app_id": "app_draft",
|
||||
"name": "Draft App",
|
||||
"is_published": false,
|
||||
"updated_at": "2026-05-31T12:31:27Z",
|
||||
},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"is_published", "online_url", "https://example.com/spark/faas/app_pub", "true", "false"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
40
shortcuts/apps/apps_release_common.go
Normal file
40
shortcuts/apps/apps_release_common.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// Gateway paths for the spark app.release OpenAPI methods.
|
||||
// Prefix reuses apiBasePath = "/open-apis/spark/v1" (same package).
|
||||
// Each path contains %s placeholders; use fmt.Sprintf to build the final URL.
|
||||
const (
|
||||
releaseCreatePath = apiBasePath + "/apps/%s/releases"
|
||||
releaseGetPath = apiBasePath + "/apps/%s/releases/%s"
|
||||
releaseListPath = apiBasePath + "/apps/%s/releases"
|
||||
)
|
||||
|
||||
// writeReleaseErrorLogTable renders a release's error_logs (a slice of
|
||||
// {step, error_log} maps from the gateway) as a two-column step/error_log
|
||||
// table via output.PrintTable. Used by +release-get to render a failed
|
||||
// release's error_logs. A nil/non-slice or
|
||||
// empty value yields an empty table (PrintTable prints "(no data)").
|
||||
func writeReleaseErrorLogTable(w io.Writer, raw interface{}) {
|
||||
logs, _ := raw.([]interface{})
|
||||
rows := make([]map[string]interface{}, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
m, ok := l.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"step": m["step"],
|
||||
"error_log": m["error_log"],
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
}
|
||||
76
shortcuts/apps/apps_release_create.go
Normal file
76
shortcuts/apps/apps_release_create.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsReleaseCreate creates a release for a Miaoda app.
|
||||
var AppsReleaseCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+release-create",
|
||||
Description: "Create a release for a Miaoda app (returns release_id for status polling)",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +release-create --app-id <app_id>",
|
||||
"Example: lark-cli apps +release-create --app-id <app_id> --branch sprint/default --dry-run",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
|
||||
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
branch := strings.TrimSpace(rctx.Str("branch"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Create a release").
|
||||
Body(buildPublishBody(branch))
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
branch := strings.TrimSpace(rctx.Str("branch"))
|
||||
path := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, buildPublishBody(branch))
|
||||
if err != nil {
|
||||
return withAppsHint(err, "if the push was rejected (non-fast-forward), sync first with `git pull --rebase origin sprint/default` then retry; inspect the failure via `lark-cli apps +release-get --app-id "+appID+" --release-id <release_id>`")
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"release_id": common.GetString(data, "release_id"),
|
||||
"status": common.GetString(data, "status"),
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "release_id: %s\nstatus: %s\n", out["release_id"], out["status"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildPublishBody builds the create-release request body. app_id is in the
|
||||
// path, not the body. branch is included only when non-empty.
|
||||
func buildPublishBody(branch string) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if branch != "" {
|
||||
body["branch"] = branch
|
||||
}
|
||||
return body
|
||||
}
|
||||
107
shortcuts/apps/apps_release_create_test.go
Normal file
107
shortcuts/apps/apps_release_create_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBuildPublishBody(t *testing.T) {
|
||||
// branch included when non-empty; app_id is NOT in body (it's in the path)
|
||||
b := buildPublishBody("feat/devops")
|
||||
if b["branch"] != "feat/devops" {
|
||||
t.Errorf("body = %v", b)
|
||||
}
|
||||
if _, ok := b["app_id"]; ok {
|
||||
t.Errorf("app_id must not be in body, got %v", b)
|
||||
}
|
||||
// branch omitted when empty
|
||||
b2 := buildPublishBody("")
|
||||
if _, ok := b2["branch"]; ok {
|
||||
t.Errorf("branch should be omitted when empty, got %v", b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseCreateMeta(t *testing.T) {
|
||||
if AppsReleaseCreate.Command != "+release-create" || AppsReleaseCreate.Risk != "write" {
|
||||
t.Errorf("meta mismatch: %+v", AppsReleaseCreate)
|
||||
}
|
||||
if len(AppsReleaseCreate.Scopes) != 1 || AppsReleaseCreate.Scopes[0] != "spark:app:write" {
|
||||
t.Errorf("scopes = %v", AppsReleaseCreate.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
// newReleaseCreateRuntimeContext builds a RuntimeContext whose cobra.Command has the
|
||||
// flags that AppsReleaseCreate.Execute reads (app-id, branch). Flag values are set
|
||||
// via the returned setter helper.
|
||||
func newReleaseCreateRuntimeContext(t *testing.T, appID, branch string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
cmd := &cobra.Command{Use: "test-release-create"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.Flags().String("app-id", "", "")
|
||||
cmd.Flags().String("branch", "", "")
|
||||
_ = cmd.Flags().Set("app-id", appID)
|
||||
if branch != "" {
|
||||
_ = cmd.Flags().Set("branch", branch)
|
||||
}
|
||||
|
||||
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
|
||||
return rctx, stdoutBuf, reg
|
||||
}
|
||||
|
||||
func TestAppsReleaseCreateExecute_Success(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newReleaseCreateRuntimeContext(t, "app_x", "main")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release_id": "123",
|
||||
"status": "publishing",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := AppsReleaseCreate.Execute(context.Background(), rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
|
||||
}
|
||||
if env.Data["release_id"] != "123" {
|
||||
t.Errorf("release_id = %v, want 123", env.Data["release_id"])
|
||||
}
|
||||
if env.Data["status"] != "publishing" {
|
||||
t.Errorf("status = %v, want publishing", env.Data["status"])
|
||||
}
|
||||
}
|
||||
80
shortcuts/apps/apps_release_get.go
Normal file
80
shortcuts/apps/apps_release_get.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsReleaseGet fetches a single release's detail by release ID.
|
||||
var AppsReleaseGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+release-get",
|
||||
Description: "Get a single release's status/detail by release ID",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +release-get --app-id <app_id> --release-id <release_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
|
||||
{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("release-id")) == "" {
|
||||
return output.ErrValidation("--release-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
releaseID := strings.TrimSpace(rctx.Str("release-id"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.GET(fmt.Sprintf(releaseGetPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(releaseID))).
|
||||
Desc("Get release detail")
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
releaseID := strings.TrimSpace(rctx.Str("release-id"))
|
||||
path := fmt.Sprintf(releaseGetPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(releaseID))
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, "if the release_id is unknown or invalid, list this app's releases with `lark-cli apps +release-list --app-id "+appID+"`")
|
||||
}
|
||||
out := data
|
||||
if release, ok := data["release"].(map[string]interface{}); ok {
|
||||
out = release
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "release_id: %v\nstatus: %v\ncreated_at: %v\nupdated_at: %v\n",
|
||||
out["release_id"], out["status"], out["created_at"], out["updated_at"])
|
||||
if commitID, ok := out["commit_id"].(string); ok && commitID != "" {
|
||||
fmt.Fprintf(w, "commit_id: %s\n", commitID)
|
||||
}
|
||||
status, _ := out["status"].(string)
|
||||
switch status {
|
||||
case "finished":
|
||||
if url, ok := out["online_url"].(string); ok && url != "" {
|
||||
fmt.Fprintf(w, "online_url: %s\n", url)
|
||||
}
|
||||
case "failed":
|
||||
writeReleaseErrorLogTable(w, out["error_logs"])
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
300
shortcuts/apps/apps_release_get_test.go
Normal file
300
shortcuts/apps/apps_release_get_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestAppsReleaseGetMeta(t *testing.T) {
|
||||
if AppsReleaseGet.Command != "+release-get" || AppsReleaseGet.Risk != "read" {
|
||||
t.Errorf("meta mismatch: %+v", AppsReleaseGet)
|
||||
}
|
||||
if len(AppsReleaseGet.Scopes) != 1 || AppsReleaseGet.Scopes[0] != "spark:app:read" {
|
||||
t.Errorf("scopes = %v", AppsReleaseGet.Scopes)
|
||||
}
|
||||
// both --app-id and --release-id must be required
|
||||
req := map[string]bool{}
|
||||
for _, f := range AppsReleaseGet.Flags {
|
||||
req[f.Name] = f.Required
|
||||
}
|
||||
if !req["app-id"] || !req["release-id"] {
|
||||
t.Errorf("app-id and release-id must be Required; flags=%+v", AppsReleaseGet.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
// newStatusRuntimeContext builds a RuntimeContext for AppsReleaseGet.Execute tests.
|
||||
func newStatusRuntimeContext(t *testing.T, appID, releaseID string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
cmd := &cobra.Command{Use: "test-release-get"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.Flags().String("app-id", "", "")
|
||||
cmd.Flags().String("release-id", "", "")
|
||||
_ = cmd.Flags().Set("app-id", appID)
|
||||
_ = cmd.Flags().Set("release-id", releaseID)
|
||||
|
||||
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
|
||||
return rctx, stdoutBuf, reg
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetExecute_Success(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases/5",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release": map[string]interface{}{
|
||||
"release_id": "5",
|
||||
"status": "finished",
|
||||
"created_at": "1700000000000",
|
||||
"updated_at": "1700000000001",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := AppsReleaseGet.Execute(context.Background(), rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
|
||||
}
|
||||
// Execute unwraps the nested "release" object
|
||||
if env.Data["release_id"] != "5" {
|
||||
t.Errorf("release_id = %v, want 5", env.Data["release_id"])
|
||||
}
|
||||
if env.Data["status"] != "finished" {
|
||||
t.Errorf("status = %v, want finished", env.Data["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyFinishedOnlineURL(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases/5",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "5", "status": "finished",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000001",
|
||||
"online_url": "https://example.feishu.cn/spark/faas/app_x",
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if !strings.Contains(out, "status: finished") {
|
||||
t.Errorf("missing base fields:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "online_url: https://example.feishu.cn/spark/faas/app_x") {
|
||||
t.Errorf("expected online_url line, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyFailedErrorLogs(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "6")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases/6",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "6", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
"error_logs": []interface{}{
|
||||
map[string]interface{}{"step": "build", "error_log": "compile error"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if !strings.Contains(out, "status: failed") {
|
||||
t.Errorf("missing base fields:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "build") || !strings.Contains(out, "compile error") {
|
||||
t.Errorf("expected error_logs table with step/error_log, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyPublishingNoExtra(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "7")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/7",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "7", "status": "publishing",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000000",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if strings.Contains(out, "online_url:") || strings.Contains(out, "error_log") {
|
||||
t.Errorf("publishing must not add extra fields, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyFinishedNoURL(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "8")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/8",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "8", "status": "finished",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000001",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if strings.Contains(stdoutBuf.String(), "online_url:") {
|
||||
t.Errorf("finished without online_url must not print the line, got:\n%s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "9")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/9",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "9", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
"error_logs": []interface{}{},
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if strings.Contains(stdoutBuf.String(), "compile error") {
|
||||
t.Errorf("empty error_logs must not render row content, got:\n%s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyCommitID(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "10")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/10",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "10", "status": "publishing",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000000",
|
||||
"commit_id": "1230aisdkjah9123913hi193",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "commit_id: 1230aisdkjah9123913hi193") {
|
||||
t.Errorf("expected commit_id line, got:\n%s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyNoCommitID(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "11")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/11",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "11", "status": "publishing",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000000",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if strings.Contains(stdoutBuf.String(), "commit_id:") {
|
||||
t.Errorf("absent commit_id must not print commit_id line, got:\n%s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyEmptyCommitID(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "12")
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/12",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "12", "status": "publishing",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000000",
|
||||
"commit_id": "",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if strings.Contains(stdoutBuf.String(), "commit_id:") {
|
||||
t.Errorf("empty commit_id must not print commit_id line, got:\n%s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetJSONOnlineURLPassthrough(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/5",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "5", "status": "finished",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000001",
|
||||
"online_url": "https://example.feishu.cn/spark/faas/app_x",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if env.Data["online_url"] != "https://example.feishu.cn/spark/faas/app_x" {
|
||||
t.Errorf("JSON must passthrough online_url, got: %v", env.Data["online_url"])
|
||||
}
|
||||
}
|
||||
98
shortcuts/apps/apps_release_list.go
Normal file
98
shortcuts/apps/apps_release_list.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsReleaseList lists a Miaoda app's release history (most recent first).
|
||||
var AppsReleaseList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+release-list",
|
||||
Description: "List a Miaoda app's release history (most recent first)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +release-list --app-id <app_id>",
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.releases[].release_id'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
|
||||
{Name: "status", Enum: []string{"publishing", "finished", "failed"}, Desc: "filter by release status: publishing | finished | failed"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (max 500)"},
|
||||
{Name: "page-token", Desc: "pagination cursor from a previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
status := strings.TrimSpace(rctx.Str("status"))
|
||||
pageSize := rctx.Int("page-size")
|
||||
pageToken := strings.TrimSpace(rctx.Str("page-token"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.GET(fmt.Sprintf(releaseListPath, validate.EncodePathSegment(appID))).
|
||||
Desc("List release history").
|
||||
Params(buildReleaseListQuery(status, pageSize, pageToken))
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
status := strings.TrimSpace(rctx.Str("status"))
|
||||
pageSize := rctx.Int("page-size")
|
||||
pageToken := strings.TrimSpace(rctx.Str("page-token"))
|
||||
path := fmt.Sprintf(releaseListPath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("GET", path, buildReleaseListQuery(status, pageSize, pageToken), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
releases, _ := data["releases"].([]interface{})
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
rows := make([]map[string]interface{}, 0, len(releases))
|
||||
for _, it := range releases {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"release_id": m["release_id"],
|
||||
"status": m["status"],
|
||||
"created_at": m["created_at"],
|
||||
"updated_at": m["updated_at"],
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildReleaseListQuery builds the list-releases query parameters. app_id is in
|
||||
// the path. page_size is always sent; status and page_token (snake) are included
|
||||
// only when non-empty.
|
||||
func buildReleaseListQuery(status string, pageSize int, pageToken string) map[string]interface{} {
|
||||
q := map[string]interface{}{
|
||||
"page_size": pageSize,
|
||||
}
|
||||
if status != "" {
|
||||
q["status"] = status
|
||||
}
|
||||
if pageToken != "" {
|
||||
q["page_token"] = pageToken
|
||||
}
|
||||
return q
|
||||
}
|
||||
129
shortcuts/apps/apps_release_list_test.go
Normal file
129
shortcuts/apps/apps_release_list_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBuildReleaseListQuery(t *testing.T) {
|
||||
// page_size always present; status/page_token omitted when empty; app_id is in the path
|
||||
q := buildReleaseListQuery("", 0, "")
|
||||
if q["page_size"] != 0 {
|
||||
t.Errorf("page_size should always be present, got %v", q)
|
||||
}
|
||||
if _, ok := q["status"]; ok {
|
||||
t.Errorf("status should be omitted when empty, got %v", q)
|
||||
}
|
||||
if _, ok := q["page_token"]; ok {
|
||||
t.Errorf("page_token should be omitted when empty, got %v", q)
|
||||
}
|
||||
q2 := buildReleaseListQuery("finished", 30, "tok")
|
||||
if q2["page_size"] != 30 {
|
||||
t.Errorf("page_size = %v, want 30", q2["page_size"])
|
||||
}
|
||||
if q2["status"] != "finished" {
|
||||
t.Errorf("status = %v, want finished", q2["status"])
|
||||
}
|
||||
if q2["page_token"] != "tok" {
|
||||
t.Errorf("page_token = %v, want tok", q2["page_token"])
|
||||
}
|
||||
if _, ok := q2["app_id"]; ok {
|
||||
t.Errorf("app_id must not be in query params, got %v", q2)
|
||||
}
|
||||
}
|
||||
|
||||
// newReleaseListRuntimeContext builds a RuntimeContext for AppsReleaseList.Execute tests.
|
||||
func newReleaseListRuntimeContext(t *testing.T, appID string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
cmd := &cobra.Command{Use: "test-release-list"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.Flags().String("app-id", "", "")
|
||||
cmd.Flags().String("status", "", "")
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
_ = cmd.Flags().Set("app-id", appID)
|
||||
|
||||
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
|
||||
return rctx, stdoutBuf, reg
|
||||
}
|
||||
|
||||
func TestAppsReleaseListExecute_Success(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newReleaseListRuntimeContext(t, "app_x")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"releases": []interface{}{
|
||||
map[string]interface{}{
|
||||
"release_id": "1",
|
||||
"status": "finished",
|
||||
"created_at": "1700000000000",
|
||||
"updated_at": "1700000000000",
|
||||
},
|
||||
},
|
||||
"next_page_token": "tok",
|
||||
"has_more": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := AppsReleaseList.Execute(context.Background(), rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
|
||||
}
|
||||
|
||||
// releases passthrough
|
||||
releases, ok := env.Data["releases"].([]interface{})
|
||||
if !ok || len(releases) != 1 {
|
||||
t.Fatalf("releases = %v", env.Data["releases"])
|
||||
}
|
||||
r0 := releases[0].(map[string]interface{})
|
||||
if r0["release_id"] != "1" {
|
||||
t.Errorf("releases[0].release_id = %v, want 1", r0["release_id"])
|
||||
}
|
||||
if r0["status"] != "finished" {
|
||||
t.Errorf("releases[0].status = %v, want finished", r0["status"])
|
||||
}
|
||||
|
||||
// pagination fields passthrough
|
||||
if env.Data["next_page_token"] != "tok" {
|
||||
t.Errorf("next_page_token = %v, want tok", env.Data["next_page_token"])
|
||||
}
|
||||
if env.Data["has_more"] != true {
|
||||
t.Errorf("has_more = %v, want true", env.Data["has_more"])
|
||||
}
|
||||
}
|
||||
58
shortcuts/apps/apps_session_create.go
Normal file
58
shortcuts/apps/apps_session_create.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsSessionCreate creates a new session under an existing Miaoda app.
|
||||
var AppsSessionCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+session-create",
|
||||
Description: "Create a session under a Miaoda app",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +session-create --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST(sessionsPath(rctx.Str("app-id"))).
|
||||
Desc("Create a session under a Miaoda app")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("POST", sessionsPath(rctx.Str("app-id")), nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "session created: %s\n", common.GetString(data, "session_id"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// sessionsPath builds the collection path for an app's sessions.
|
||||
func sessionsPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/sessions", apiBasePath, validate.EncodePathSegment(strings.TrimSpace(appID)))
|
||||
}
|
||||
85
shortcuts/apps/apps_session_create_test.go
Normal file
85
shortcuts/apps/apps_session_create_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsSessionCreate_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"session_id": "conv_new"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsSessionCreate,
|
||||
[]string{"+session-create", "--app-id", "app_x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"session_id": "conv_new"`) {
|
||||
t.Fatalf("stdout missing session_id: %s", got)
|
||||
}
|
||||
if len(stub.CapturedBody) != 0 {
|
||||
t.Fatalf("+session-create must POST with no body, got: %s", stub.CapturedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionCreate_Pretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"session_id": "conv_new"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsSessionCreate,
|
||||
[]string{"+session-create", "--app-id", "app_x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "session created: conv_new") {
|
||||
t.Fatalf("pretty output wrong: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionCreate_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// present-but-blank --app-id passes cobra MarkFlagRequired, caught by Validate hook.
|
||||
err := runAppsShortcut(t, AppsSessionCreate, []string{"+session-create", "--app-id", "", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "app-id") {
|
||||
t.Fatalf("expected --app-id required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionCreate_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionCreate,
|
||||
[]string{"+session-create", "--app-id", "app_x", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionCreate_EncodesAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionCreate,
|
||||
[]string{"+session-create", "--app-id", "a/b", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "apps/a/b/sessions") {
|
||||
t.Fatalf("app_id must be path-encoded, got raw slash: %s", got)
|
||||
}
|
||||
}
|
||||
73
shortcuts/apps/apps_session_get.go
Normal file
73
shortcuts/apps/apps_session_get.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsSessionGet reads a session's current status, queued turns, and latest turn.
|
||||
// Single-shot: the caller drives polling using next_poll_after_ms.
|
||||
var AppsSessionGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+session-get",
|
||||
Description: "Read a session's current status, queued turns, and latest turn",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +session-get --app-id <app_id> --session-id <session_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "session-id", Desc: "session ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET(sessionPath(rctx.Str("app-id"), rctx.Str("session-id"))).
|
||||
Desc("Read a session's status")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("GET", sessionPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, "if the session_id is unknown or invalid, list this app's sessions with `lark-cli apps +session-list --app-id "+strings.TrimSpace(rctx.Str("app-id"))+"`")
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "session: %s\n", common.GetString(data, "session_id"))
|
||||
fmt.Fprintf(w, "active: %v streaming: %v\n", data["is_active"], data["is_streaming"])
|
||||
if lt, ok := data["latest_turn"].(map[string]interface{}); ok {
|
||||
fmt.Fprintf(w, "latest turn: %v (%v)\n", lt["turn_id"], lt["status"])
|
||||
}
|
||||
fmt.Fprintf(w, "queued: %v\n", data["queued_count"])
|
||||
fmt.Fprintf(w, "next poll after: %vms\n", data["next_poll_after_ms"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// sessionPath builds the single-session path under an app. Defined here (first
|
||||
// consumer) so it never sits unused. Reused by Task 4 (+session-stop) and Task 5 (+chat).
|
||||
func sessionPath(appID, sessionID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/sessions/%s",
|
||||
apiBasePath,
|
||||
validate.EncodePathSegment(strings.TrimSpace(appID)),
|
||||
validate.EncodePathSegment(strings.TrimSpace(sessionID)))
|
||||
}
|
||||
80
shortcuts/apps/apps_session_get_test.go
Normal file
80
shortcuts/apps/apps_session_get_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func sessionGetStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"session_id": "conv_x",
|
||||
"is_active": true,
|
||||
"is_streaming": true,
|
||||
"summary": "正在补充...",
|
||||
"queued_count": 1,
|
||||
"latest_turn": map[string]interface{}{"turn_id": "8421374923", "status": "running"},
|
||||
"next_poll_after_ms": 30000,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionGet_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(sessionGetStub())
|
||||
if err := runAppsShortcut(t, AppsSessionGet,
|
||||
[]string{"+session-get", "--app-id", "app_x", "--session-id", "conv_x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"is_streaming": true`) {
|
||||
t.Fatalf("stdout missing is_streaming: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionGet_PrettyReadsNestedSnakeCase(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(sessionGetStub())
|
||||
if err := runAppsShortcut(t, AppsSessionGet,
|
||||
[]string{"+session-get", "--app-id", "app_x", "--session-id", "conv_x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "8421374923") || !strings.Contains(got, "running") {
|
||||
t.Fatalf("pretty must read latest_turn.turn_id/status: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "30000") {
|
||||
t.Fatalf("pretty must show next_poll_after_ms: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionGet_RequiresFlags(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsSessionGet, []string{"+session-get", "--app-id", "app_x", "--session-id", "", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "session-id") {
|
||||
t.Fatalf("expected --session-id required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionGet_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionGet,
|
||||
[]string{"+session-get", "--app-id", "app_x", "--session-id", "conv_x", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
}
|
||||
79
shortcuts/apps/apps_session_list.go
Normal file
79
shortcuts/apps/apps_session_list.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsSessionList lists sessions under a Miaoda app (cursor pagination, single page).
|
||||
var AppsSessionList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+session-list",
|
||||
Description: "List sessions under a Miaoda app (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +session-list --app-id <app_id>",
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.sessions[].session_id'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (max 50)"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET(sessionsPath(rctx.Str("app-id"))).
|
||||
Desc("List sessions under a Miaoda app").
|
||||
Params(buildSessionListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("GET", sessionsPath(rctx.Str("app-id")), buildSessionListParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
sessions, _ := data["sessions"].([]interface{})
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
rows := make([]map[string]interface{}, 0, len(sessions))
|
||||
for _, item := range sessions {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"session_id": m["session_id"],
|
||||
"name": m["name"],
|
||||
"is_active": m["is_active"],
|
||||
"updated_at": m["updated_at"],
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildSessionListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
return params
|
||||
}
|
||||
89
shortcuts/apps/apps_session_list_test.go
Normal file
89
shortcuts/apps/apps_session_list_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsSessionList_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"sessions": []interface{}{
|
||||
map[string]interface{}{
|
||||
"session_id": "conv_a", "name": "建后台", "is_active": true,
|
||||
"created_at": "2026-05-28T10:00:00Z", "updated_at": "2026-05-28T11:00:00Z",
|
||||
},
|
||||
},
|
||||
"next_page_token": "",
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsSessionList,
|
||||
[]string{"+session-list", "--app-id", "app_x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"session_id": "conv_a"`) {
|
||||
t.Fatalf("stdout missing session: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionList_TableShowsKeyColumns(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"sessions": []interface{}{
|
||||
map[string]interface{}{"session_id": "conv_a", "name": "建后台", "is_active": true, "updated_at": "2026-05-28T11:00:00Z"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsSessionList,
|
||||
[]string{"+session-list", "--app-id", "app_x", "--format", "table", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "conv_a") || !strings.Contains(got, "建后台") {
|
||||
t.Fatalf("table missing key columns: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionList_PassesPagination(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionList,
|
||||
[]string{"+session-list", "--app-id", "app_x", "--page-size", "50", "--page-token", "tok1", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "page_size") || !strings.Contains(got, "50") {
|
||||
t.Fatalf("dry-run missing page_size: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "tok1") {
|
||||
t.Fatalf("dry-run missing page_token: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionList_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsSessionList, []string{"+session-list", "--app-id", "", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "app-id") {
|
||||
t.Fatalf("expected --app-id required error, got %v", err)
|
||||
}
|
||||
}
|
||||
80
shortcuts/apps/apps_session_stop.go
Normal file
80
shortcuts/apps/apps_session_stop.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const sessionStopHint = "verify --app-id and --session-id are correct (list sessions with `lark-cli apps +session-list --app-id <app_id>`); --turn-id must be the latest turn from `lark-cli apps +session-get --app-id <app_id> --session-id <session_id>`"
|
||||
|
||||
// AppsSessionStop interrupts the RUNNING turn of a session. No-op if the turn
|
||||
// is queued or already finished. Does not close the session.
|
||||
var AppsSessionStop = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+session-stop",
|
||||
Description: "Stop (interrupt) the running turn of a session",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +session-stop --app-id <app_id> --session-id <session_id> --turn-id <turn_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "session-id", Desc: "session ID", Required: true},
|
||||
{Name: "turn-id", Desc: "turn ID to stop (from +session-get latest_turn.turn_id)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("turn-id")) == "" {
|
||||
return output.ErrValidation("--turn-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST(stopPath(rctx.Str("app-id"), rctx.Str("session-id"))).
|
||||
Desc("Stop the running turn of a session").
|
||||
Body(buildStopBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("POST", stopPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, buildStopBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, sessionStopHint)
|
||||
}
|
||||
turnID := strings.TrimSpace(rctx.Str("turn-id"))
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
stopped, _ := data["stopped"].(bool)
|
||||
if stopped {
|
||||
fmt.Fprintf(w, "stopped turn %s. %v\n", turnID, data["message"])
|
||||
} else {
|
||||
fmt.Fprintf(w, "no-op: turn %s not stopped. %v\n", turnID, data["message"])
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func stopPath(appID, sessionID string) string {
|
||||
return sessionPath(appID, sessionID) + "/stop"
|
||||
}
|
||||
|
||||
func buildStopBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"turn_id": strings.TrimSpace(rctx.Str("turn-id")),
|
||||
}
|
||||
}
|
||||
110
shortcuts/apps/apps_session_stop_test.go
Normal file
110
shortcuts/apps/apps_session_stop_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsSessionStop_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"stopped": true, "message": "running turn stopped"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "8421374923", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["turn_id"] != "8421374923" {
|
||||
t.Fatalf("body.turn_id = %v", sent["turn_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionStop_PrettyStopped(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"stopped": true, "message": "running turn stopped"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "8421374923", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "stopped turn 8421374923") {
|
||||
t.Fatalf("pretty stopped wrong: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionStop_PrettyNoOp(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"stopped": false, "message": "turn already completed"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "t1", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "no-op") || !strings.Contains(got, "completed") {
|
||||
t.Fatalf("pretty no-op wrong: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionStop_RequiresTurnID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "turn-id") {
|
||||
t.Fatalf("expected --turn-id required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionStop_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "t1", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"turn_id": "t1"`) {
|
||||
t.Fatalf("dry-run missing turn_id body: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Encoding safeguard for the shared sessionPath helper (reused from Task 3).
|
||||
func TestAppsSessionStop_EncodesPathSegments(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionStop,
|
||||
[]string{"+session-stop", "--app-id", "a/b", "--session-id", "c/d", "--turn-id", "t1", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if strings.Contains(got, "apps/a/b/sessions") || strings.Contains(got, "sessions/c/d/stop") {
|
||||
t.Fatalf("path segments must be encoded, got raw slash: %s", got)
|
||||
}
|
||||
}
|
||||
327
shortcuts/apps/apps_skill_consistency_test.go
Normal file
327
shortcuts/apps/apps_skill_consistency_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// frameworkGlobalFlags are injected by shortcuts/common/runner.go for every (or
|
||||
// many) shortcuts, so they are always allowed in skill docs regardless of which
|
||||
// command they are attached to. See registerShortcutFlagsWithContext in
|
||||
// shortcuts/common/runner.go: --dry-run, --format, --json, --jq/-q are injected
|
||||
// unconditionally; --as via the identity flag; --yes for high-risk-write;
|
||||
// --print-schema/--flag-name for shortcuts that opt into schema introspection;
|
||||
// --help/-h are cobra built-ins.
|
||||
var frameworkGlobalFlags = map[string]bool{
|
||||
"dry-run": true, "format": true, "json": true, "yes": true,
|
||||
"jq": true, "q": true, "as": true,
|
||||
"print-schema": true, "flag-name": true, "help": true, "h": true,
|
||||
}
|
||||
|
||||
// cmdRef is one apps command invocation extracted from a skill doc.
|
||||
type cmdRef struct {
|
||||
cmd string // registered command form, includes the leading '+'
|
||||
flags []string // long flag names without '--', short flags without '-'
|
||||
}
|
||||
|
||||
var (
|
||||
// cmdTokenRe matches a shortcut command token. The leading '+' is the
|
||||
// reliable signal; the body is a-z plus digits/hyphens. A real command
|
||||
// never ends in '-', so a trailing hyphen (from a glob like `+db-*`) is
|
||||
// stripped/rejected separately.
|
||||
cmdTokenRe = regexp.MustCompile(`\+[a-z][a-z0-9-]*`)
|
||||
longFlagRe = regexp.MustCompile(`^--([a-z][a-z0-9-]*)`)
|
||||
shortFlagRe = regexp.MustCompile(`^-([a-z])$`)
|
||||
// bareWordRe matches a plain lowercase word (a CLI service/qualifier token
|
||||
// like "apps", "contact", "im", "code") with no markdown decoration.
|
||||
bareWordRe = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
)
|
||||
|
||||
// documentedNonexistentExamples are apps-prefixed command tokens the lark-apps
|
||||
// docs deliberately cite as NOT being apps commands (negative examples that
|
||||
// warn agents away from inventing them). They are intentionally absent from
|
||||
// Shortcuts(); excluding them here is narrow and explicit, unlike skipping any
|
||||
// line containing "不存在" (a common Chinese word meaning "does not exist")
|
||||
// which would mask real drift on unrelated lines.
|
||||
//
|
||||
// Source lines (both files carry the identical sentence):
|
||||
// - skills/lark-apps/references/lark-apps-local-dev.md:52
|
||||
// - skills/lark-apps/references/lark-apps-git-credential.md:35
|
||||
// "...不存在 `apps +pull` / `apps +push` / `apps code +read` 这类...shortcut..."
|
||||
//
|
||||
// Only `+pull` and `+push` are apps-prefixed and thus need an explicit entry
|
||||
// here. `apps code +read` is preceded by the bare qualifier word "code" (not
|
||||
// "apps"), so the cross-service filter already rejects it; adding it would be
|
||||
// redundant double-coverage, so it is intentionally omitted.
|
||||
var documentedNonexistentExamples = map[string]bool{
|
||||
"+pull": true,
|
||||
"+push": true,
|
||||
}
|
||||
|
||||
// extractCmdRefs joins backslash-continued lines, then for each `+<cmd>` token
|
||||
// captures the --flags/-q that follow it, stopping at the next `+<cmd>` token, a
|
||||
// shell separator (| && ;), or the end of the inline-code span the command
|
||||
// appears in. Flags only attach within the same backtick-delimited segment as
|
||||
// the command, because skill docs write a real invocation inside one code span
|
||||
// (`lark-cli apps +create --name x`) while a stray `--flag` discussed in prose
|
||||
// (e.g. "`+git-credential-list` ... 不需要 `--app-id`") lives in a separate
|
||||
// span and must not attach.
|
||||
//
|
||||
// To avoid false positives it also:
|
||||
// - skips a `+token` immediately preceded by a bare service/qualifier word
|
||||
// other than "apps" (e.g. `contact +search-user`, `im +chat-search`,
|
||||
// `apps code +read`) — those are not apps shortcuts;
|
||||
// - rejects a token that ends in '-' (a wildcard family like `+db-*`,
|
||||
// `+release-*`), since no registered command ends in a hyphen.
|
||||
//
|
||||
// Deliberate negative examples (documentedNonexistentExamples, e.g. `+pull`)
|
||||
// are still extracted here; the consistency gate skips them explicitly when an
|
||||
// unregistered command turns out to be one of those documented examples.
|
||||
func extractCmdRefs(doc string) []cmdRef {
|
||||
var refs []cmdRef
|
||||
for _, logical := range logicalLines(doc) {
|
||||
// Split the logical line into backtick-delimited segments. A command and
|
||||
// its flags only travel together within one segment; crossing a backtick
|
||||
// boundary resets the capture context. Code-block lines (no backticks)
|
||||
// are a single segment and behave like a normal command line.
|
||||
var cur *cmdRef
|
||||
var prevClean string
|
||||
for _, seg := range strings.Split(logical, "`") {
|
||||
cur = nil // a new inline span never inherits the previous command
|
||||
prevClean = ""
|
||||
for _, tok := range strings.Fields(seg) {
|
||||
clean := strings.Trim(tok, ",'\"()*")
|
||||
if tok == "|" || tok == "&&" || tok == ";" {
|
||||
cur = nil
|
||||
prevClean = clean
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(clean, "+") {
|
||||
m := cmdTokenRe.FindString(clean)
|
||||
if m == "" || strings.HasSuffix(m, "-") {
|
||||
// Not a real command shape (e.g. "+1") or a wildcard
|
||||
// family like "+db-" from `+db-*`. No capture context.
|
||||
cur = nil
|
||||
prevClean = clean
|
||||
continue
|
||||
}
|
||||
// Cross-service reference: nearest preceding bare word is a
|
||||
// service/qualifier other than "apps".
|
||||
if bareWordRe.MatchString(prevClean) && prevClean != "apps" && prevClean != "lark-cli" {
|
||||
cur = nil
|
||||
prevClean = clean
|
||||
continue
|
||||
}
|
||||
refs = append(refs, cmdRef{cmd: m})
|
||||
cur = &refs[len(refs)-1]
|
||||
prevClean = clean
|
||||
continue
|
||||
}
|
||||
if cur != nil {
|
||||
if m := longFlagRe.FindStringSubmatch(clean); m != nil {
|
||||
cur.flags = append(cur.flags, m[1])
|
||||
} else if m := shortFlagRe.FindStringSubmatch(clean); m != nil {
|
||||
cur.flags = append(cur.flags, m[1])
|
||||
}
|
||||
}
|
||||
prevClean = clean
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// logicalLines merges lines ending with a backslash into one logical line.
|
||||
func logicalLines(doc string) []string {
|
||||
raw := strings.Split(strings.ReplaceAll(doc, "\r\n", "\n"), "\n")
|
||||
var out []string
|
||||
var buf strings.Builder
|
||||
carrying := false
|
||||
for _, ln := range raw {
|
||||
t := strings.TrimRight(ln, " \t")
|
||||
if strings.HasSuffix(t, "\\") {
|
||||
buf.WriteString(strings.TrimSuffix(t, "\\"))
|
||||
buf.WriteString(" ")
|
||||
carrying = true
|
||||
continue
|
||||
}
|
||||
buf.WriteString(ln)
|
||||
out = append(out, buf.String())
|
||||
buf.Reset()
|
||||
carrying = false
|
||||
}
|
||||
if carrying || buf.Len() > 0 {
|
||||
out = append(out, buf.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestExtractCmdRefs_Unit(t *testing.T) {
|
||||
doc := "`lark-cli apps +create --name x --app-type html`\n" +
|
||||
"`+db-table-list`, `+db-table-get`\n" +
|
||||
"lark-cli apps +session-list --app-id x | jq '.y --post-pipe-flag'\n" +
|
||||
"lark-cli apps +foo --bar baz \\\n --qux 1\n" +
|
||||
"人名→`ou_` 用 `lark-cli contact +search-user --query <名字>`,群名→`oc_` 用 `lark-cli im +chat-search --query <群名>`\n" +
|
||||
"改库走 `+db-*`;发布走 `+release-*`\n" +
|
||||
"不存在 `apps +pull` / `apps +push` / `apps code +read` 这类 shortcut,不要臆造。\n" +
|
||||
"`+git-credential-list` 列出本地凭证,不需要 `--app-id`。\n"
|
||||
|
||||
refs := extractCmdRefs(doc)
|
||||
got := map[string][]string{}
|
||||
for _, r := range refs {
|
||||
got[r.cmd] = append(got[r.cmd], r.flags...)
|
||||
}
|
||||
|
||||
// Full invocation: command + both flags captured.
|
||||
if _, ok := got["+create"]; !ok {
|
||||
t.Fatalf("missing +create; got %+v", refs)
|
||||
}
|
||||
if !contains(got["+create"], "name") || !contains(got["+create"], "app-type") {
|
||||
t.Errorf("+create flags wrong: %v", got["+create"])
|
||||
}
|
||||
|
||||
// Comma-separated command list: no flags attach to either command.
|
||||
if _, ok := got["+db-table-list"]; !ok {
|
||||
t.Errorf("missing +db-table-list; got %+v", refs)
|
||||
}
|
||||
if len(got["+db-table-list"]) != 0 || len(got["+db-table-get"]) != 0 {
|
||||
t.Errorf("comma-separated commands must carry no flags: %v", got)
|
||||
}
|
||||
|
||||
// Pipe stops capture within a SINGLE span (no surrounding backticks), so the
|
||||
// pipe `|` is the only boundary that can stop flag capture here: --app-id
|
||||
// (before the pipe) attaches, but the post-pipe --post-pipe-flag must NOT.
|
||||
if !contains(got["+session-list"], "app-id") {
|
||||
t.Errorf("pre-pipe flag should attach to +session-list: %v", got["+session-list"])
|
||||
}
|
||||
if contains(got["+session-list"], "post-pipe-flag") {
|
||||
t.Errorf("pipe did not stop flag capture: %v", got["+session-list"])
|
||||
}
|
||||
|
||||
// Backslash continuation joins --qux onto +foo (same logical line).
|
||||
if !contains(got["+foo"], "bar") || !contains(got["+foo"], "qux") {
|
||||
t.Errorf("continuation join failed: %v", got["+foo"])
|
||||
}
|
||||
|
||||
// Cross-service commands must NOT be attributed to apps.
|
||||
if _, ok := got["+search-user"]; ok {
|
||||
t.Errorf("contact +search-user must not be extracted as an apps command: %+v", refs)
|
||||
}
|
||||
if _, ok := got["+chat-search"]; ok {
|
||||
t.Errorf("im +chat-search must not be extracted as an apps command: %+v", refs)
|
||||
}
|
||||
|
||||
// Wildcard family references must NOT be extracted as commands.
|
||||
if _, ok := got["+db-"]; ok {
|
||||
t.Errorf("`+db-*` wildcard must not be extracted as a command: %+v", refs)
|
||||
}
|
||||
if _, ok := got["+release-"]; ok {
|
||||
t.Errorf("`+release-*` wildcard must not be extracted as a command: %+v", refs)
|
||||
}
|
||||
|
||||
// Deliberate negative examples are no longer line-skipped: the apps-prefixed
|
||||
// `+pull` / `+push` ARE extracted here (the consistency gate later excludes
|
||||
// them via documentedNonexistentExamples). `apps code +read` is preceded by
|
||||
// the bare qualifier "code", so the cross-service filter still drops it.
|
||||
for _, tok := range []string{"+pull", "+push"} {
|
||||
if _, ok := got[tok]; !ok {
|
||||
t.Errorf("negative example %s should still be extracted (gate excludes it, not the extractor): %+v", tok, refs)
|
||||
}
|
||||
if !documentedNonexistentExamples[tok] {
|
||||
t.Errorf("%s must be in documentedNonexistentExamples allowlist", tok)
|
||||
}
|
||||
}
|
||||
if _, ok := got["+read"]; ok {
|
||||
t.Errorf("`apps code +read` is cross-service (preceded by `code`) and must not be extracted: %+v", refs)
|
||||
}
|
||||
|
||||
// A --flag discussed in prose, in a separate inline-code span from the
|
||||
// command, must NOT attach to the command (backtick-span boundary stops
|
||||
// capture).
|
||||
if contains(got["+git-credential-list"], "app-id") {
|
||||
t.Errorf("prose flag in a separate backtick span must not attach: %v", got["+git-credential-list"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillDocsCommandsConsistentWithShortcuts(t *testing.T) {
|
||||
// Source of truth: the registered shortcuts and their flags.
|
||||
validCmd := map[string]map[string]bool{}
|
||||
for _, s := range Shortcuts() {
|
||||
fl := map[string]bool{}
|
||||
for _, f := range s.Flags {
|
||||
fl[f.Name] = true
|
||||
}
|
||||
validCmd[s.Command] = fl
|
||||
}
|
||||
|
||||
docs := skillDocFiles(t)
|
||||
if len(docs) == 0 {
|
||||
t.Fatal("no lark-apps skill docs found; gate cannot run")
|
||||
}
|
||||
|
||||
for _, path := range docs {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
rel := filepath.Base(path)
|
||||
for _, ref := range extractCmdRefs(string(raw)) {
|
||||
flags, ok := validCmd[ref.cmd]
|
||||
if !ok {
|
||||
// A deliberate negative example (documented as NOT existing) is
|
||||
// expected to be absent from Shortcuts(); skip only those.
|
||||
if documentedNonexistentExamples[ref.cmd] {
|
||||
continue
|
||||
}
|
||||
t.Errorf("%s: references `apps %s` which is not a registered shortcut", rel, ref.cmd)
|
||||
continue
|
||||
}
|
||||
for _, fl := range ref.flags {
|
||||
if flags[fl] || frameworkGlobalFlags[fl] {
|
||||
continue
|
||||
}
|
||||
t.Errorf("%s: `apps %s --%s`: --%s is not a flag of %s (have: %s)",
|
||||
rel, ref.cmd, fl, fl, ref.cmd, sortedFlags(flags))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// skillDocFiles returns SKILL.md + references/*.md for lark-apps, relative to
|
||||
// this package dir (go test cwd = shortcuts/apps/).
|
||||
func skillDocFiles(t *testing.T) []string {
|
||||
t.Helper()
|
||||
base := filepath.Join("..", "..", "skills", "lark-apps")
|
||||
var out []string
|
||||
if _, err := os.Stat(filepath.Join(base, "SKILL.md")); err == nil {
|
||||
out = append(out, filepath.Join(base, "SKILL.md"))
|
||||
}
|
||||
refs, _ := filepath.Glob(filepath.Join(base, "references", "*.md"))
|
||||
out = append(out, refs...)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedFlags(m map[string]bool) string {
|
||||
names := make([]string, 0, len(m))
|
||||
for n := range m {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func contains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -20,9 +20,13 @@ var AppsUpdate = common.Shortcut{
|
||||
Command: "+update",
|
||||
Description: "Partially update a Miaoda app (only provided fields are sent)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
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"},
|
||||
@@ -48,9 +52,9 @@ var AppsUpdate = common.Shortcut{
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPI("PATCH", path, nil, buildAppsUpdateBody(rctx))
|
||||
data, err := rctx.CallAPITyped("PATCH", path, nil, buildAppsUpdateBody(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "updated: %s\n", common.GetString(data, "app", "app_id"))
|
||||
|
||||
@@ -8,9 +8,46 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func testRuntimeWithNameDesc(t *testing.T, name, desc string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "update"}
|
||||
cmd.Flags().String("name", name, "")
|
||||
cmd.Flags().String("description", desc, "")
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
func TestBuildAppsUpdateBody_FieldCombos(t *testing.T) {
|
||||
t.Run("both empty -> empty body", func(t *testing.T) {
|
||||
if body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, " ", "")); len(body) != 0 {
|
||||
t.Errorf("empty inputs should yield empty body, got %v", body)
|
||||
}
|
||||
})
|
||||
t.Run("name only", func(t *testing.T) {
|
||||
body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, "App", ""))
|
||||
if body["name"] != "App" || len(body) != 1 {
|
||||
t.Errorf("name-only body=%v", body)
|
||||
}
|
||||
})
|
||||
t.Run("description only", func(t *testing.T) {
|
||||
body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, "", "desc"))
|
||||
if body["description"] != "desc" || len(body) != 1 {
|
||||
t.Errorf("desc-only body=%v", body)
|
||||
}
|
||||
})
|
||||
t.Run("both set and trimmed", func(t *testing.T) {
|
||||
body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, " App ", " d "))
|
||||
if body["name"] != "App" || body["description"] != "d" {
|
||||
t.Errorf("both body=%v", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppsUpdate_PartialFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
|
||||
48
shortcuts/apps/command_runner.go
Normal file
48
shortcuts/apps/command_runner.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// commandRunner abstracts external process execution so apps +init's
|
||||
// orchestration can be unit-tested without a real git binary or network.
|
||||
// dir == "" runs in the current working directory; a non-empty dir runs the
|
||||
// command with that working directory (git -C semantics).
|
||||
type commandRunner interface {
|
||||
Run(ctx context.Context, dir, name string, args ...string) (stdout, stderr string, err error)
|
||||
}
|
||||
|
||||
// execCommandRunner is the production commandRunner backed by os/exec.
|
||||
type execCommandRunner struct{}
|
||||
|
||||
func (execCommandRunner) Run(ctx context.Context, dir, name string, args ...string) (string, string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
// credentialURLRe matches the userinfo segment of an http(s) URL (the
|
||||
// "user:token@" part) so it can be redacted before any output or logging. The
|
||||
// negated class excludes only "/" and whitespace (not "@"), so the match
|
||||
// greedily consumes up to the LAST "@" before the host/path — this ensures a
|
||||
// literal "@" inside the userinfo (e.g. "user:p@ss@host") is fully redacted.
|
||||
var credentialURLRe = regexp.MustCompile(`(?i)(https?://)[^/\s]+@`)
|
||||
|
||||
// redactURLCredentials replaces the userinfo segment of any http(s) URL in s
|
||||
// with "***". Safe to call on both a bare repo_url and free-form text such as
|
||||
// git stderr (which echoes the full remote URL on failure).
|
||||
func redactURLCredentials(s string) string {
|
||||
return credentialURLRe.ReplaceAllString(s, "${1}***@")
|
||||
}
|
||||
63
shortcuts/apps/command_runner_test.go
Normal file
63
shortcuts/apps/command_runner_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactURLCredentials(t *testing.T) {
|
||||
cases := []struct{ name, in, want string }{
|
||||
{"http with userinfo", "http://x-token:PAT_abc@git.host/app_x.git", "http://***@git.host/app_x.git"},
|
||||
{"https with userinfo", "https://u:p@h/r.git", "https://***@h/r.git"},
|
||||
{"no userinfo unchanged", "http://git.host/app_x.git", "http://git.host/app_x.git"},
|
||||
{"embedded in stderr text", "fatal: unable to access 'http://u:t@h/r.git/': 401", "fatal: unable to access 'http://***@h/r.git/': 401"},
|
||||
{"empty", "", ""},
|
||||
{"non-url unchanged", "some error message", "some error message"},
|
||||
{"uppercase scheme", "HTTP://u:t@h/r.git", "HTTP://***@h/r.git"},
|
||||
{"multiple @ in userinfo", "https://user:p@ss@host/r.git", "https://***@host/r.git"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := redactURLCredentials(c.in); got != c.want {
|
||||
t.Errorf("redactURLCredentials(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeCommandRunner records calls and returns scripted results keyed by the
|
||||
// command + first arg (e.g. "git clone", "git checkout", "git status"), or
|
||||
// "credential-init" for the self-invoked `apps +git-credential-init` call.
|
||||
type fakeCallResult struct {
|
||||
stdout, stderr string
|
||||
err error
|
||||
}
|
||||
|
||||
type fakeCommandRunner struct {
|
||||
results map[string]fakeCallResult
|
||||
calls [][]string // each entry: [dir, name, args...]
|
||||
}
|
||||
|
||||
func (f *fakeCommandRunner) Run(ctx context.Context, dir, name string, args ...string) (string, string, error) {
|
||||
rec := append([]string{dir, name}, args...)
|
||||
f.calls = append(f.calls, rec)
|
||||
key := name
|
||||
if len(args) > 0 {
|
||||
key = name + " " + args[0]
|
||||
}
|
||||
if name != "git" && len(args) >= 2 && args[0] == "apps" {
|
||||
switch args[1] {
|
||||
case "+env-pull":
|
||||
key = "env-pull"
|
||||
default:
|
||||
key = "credential-init"
|
||||
}
|
||||
}
|
||||
if r, ok := f.results[key]; ok {
|
||||
return r.stdout, r.stderr, r.err
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
@@ -3,8 +3,50 @@
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// appsService 是 CLI 命令的 service 前缀(lark-cli apps ...)。
|
||||
const appsService = "apps"
|
||||
|
||||
// apiBasePath is the registered OAPI prefix for the Miaoda apps domain.
|
||||
const apiBasePath = "/open-apis/spark/v1"
|
||||
|
||||
// appIDListHint is the shared recovery hint for commands whose most likely
|
||||
// failure cause is a wrong/inaccessible --app-id. It points at +list to find
|
||||
// the correct Miaoda app id. The app_/cli_ format rule is taught in
|
||||
// lark-apps SKILL.md ("app_id 获取"); the hint stays lean and does not repeat it.
|
||||
const appIDListHint = "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`"
|
||||
|
||||
// withAppsHint attaches an actionable next-step hint to a failure returned by
|
||||
// CallAPI, preserving its original classification (typed subtype/code/log_id or
|
||||
// legacy detail). A hint already present on the error is kept (the upstream
|
||||
// wording wins); only an empty hint is filled in. Mirrors
|
||||
// drive.appendDriveExportRecoveryHint. err==nil passes through.
|
||||
func withAppsHint(err error, hint string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// p points at the embedded Problem, so the mutation is reflected in err.
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) == "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Legacy *output.ExitError fallback: fill the hint in place, preserving the
|
||||
// original class / exit code rather than downgrading the error.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) == "" {
|
||||
exitErr.Detail.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
66
shortcuts/apps/common_test.go
Normal file
66
shortcuts/apps/common_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWithAppsHint(t *testing.T) {
|
||||
t.Run("nil error stays nil", func(t *testing.T) {
|
||||
if got := withAppsHint(nil, "do x"); got != nil {
|
||||
t.Fatalf("withAppsHint(nil) = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty hint gets filled, code/type preserved", func(t *testing.T) {
|
||||
in := &output.ExitError{Code: 1, Detail: &output.ErrDetail{Type: "api_error", Message: "boom"}}
|
||||
out := withAppsHint(in, "run +release-list")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(out, &exitErr) {
|
||||
t.Fatalf("returned error is not *output.ExitError: %T", out)
|
||||
}
|
||||
if exitErr.Detail.Hint != "run +release-list" {
|
||||
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "run +release-list")
|
||||
}
|
||||
if exitErr.Code != 1 || exitErr.Detail.Type != "api_error" || exitErr.Detail.Message != "boom" {
|
||||
t.Errorf("code/type/message mutated: code=%d type=%q msg=%q", exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing hint is preserved, not clobbered", func(t *testing.T) {
|
||||
in := output.ErrWithHint(1, "api_error", "boom", "original hint")
|
||||
out := withAppsHint(in, "new hint")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(out, &exitErr) {
|
||||
t.Fatalf("returned error is not *output.ExitError: %T", out)
|
||||
}
|
||||
if exitErr.Detail.Hint != "original hint" {
|
||||
t.Errorf("Hint = %q, want preserved %q", exitErr.Detail.Hint, "original hint")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank-whitespace hint is treated as empty and filled", func(t *testing.T) {
|
||||
in := output.ErrWithHint(1, "api_error", "boom", " ")
|
||||
out := withAppsHint(in, "filled hint")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(out, &exitErr) {
|
||||
t.Fatalf("returned error is not *output.ExitError: %T", out)
|
||||
}
|
||||
if exitErr.Detail.Hint != "filled hint" {
|
||||
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "filled hint")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrecognized error type returned unchanged, no panic", func(t *testing.T) {
|
||||
in := errors.New("plain")
|
||||
out := withAppsHint(in, "ignored")
|
||||
if out == nil || out.Error() != "plain" {
|
||||
t.Fatalf("withAppsHint(plain) = %v, want unchanged plain error", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
43
shortcuts/apps/db_common.go
Normal file
43
shortcuts/apps/db_common.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// URL helpers for the db CLI commands.
|
||||
|
||||
// appTablesPath 返回 app db 表列表 URL(复用存量「获取数据表列表」接口)。
|
||||
func appTablesPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/tables", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appTablePath 返回单个 app db 表详情 URL(复用存量「获取数据表详细信息」接口)。
|
||||
func appTablePath(appID, table string) string {
|
||||
return appTablesPath(appID) + "/" + validate.EncodePathSegment(table)
|
||||
}
|
||||
|
||||
// appSQLPath 返回 app db SQL 执行 URL(复用存量「执行 SQL」接口)。
|
||||
func appSQLPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/sql_commands", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appDbEnvCreatePath 返回 app db 环境创建 URL(服务端接口名仍为 db_dev_init)。
|
||||
func appDbEnvCreatePath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/db_dev_init", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// requireAppID trims --app-id and rejects blank, returning a uniform validation error.
|
||||
func requireAppID(raw string) (string, error) {
|
||||
id := strings.TrimSpace(raw)
|
||||
if id == "" {
|
||||
return "", output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
41
shortcuts/apps/db_common_test.go
Normal file
41
shortcuts/apps/db_common_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAppTablesPath_ReusesExistingURL(t *testing.T) {
|
||||
if got := appTablesPath("app_x"); got != "/open-apis/spark/v1/apps/app_x/tables" {
|
||||
t.Fatalf("appTablesPath = %q (want existing /apps/{id}/tables, not /db/tables)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppTablePath_EncodesSegments(t *testing.T) {
|
||||
if got := appTablePath("app_x", "my table"); got != "/open-apis/spark/v1/apps/app_x/tables/my%20table" {
|
||||
t.Fatalf("appTablePath = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppSQLPath_ReusesExistingURL(t *testing.T) {
|
||||
if got := appSQLPath("app_x"); got != "/open-apis/spark/v1/apps/app_x/sql_commands" {
|
||||
t.Fatalf("appSQLPath = %q (want /apps/{id}/sql_commands)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDbEnvCreatePath_NewURL(t *testing.T) {
|
||||
// db-env-create 是本期新增接口,URL 走 /db_dev_init(与上面三条复用 URL 不同)。
|
||||
if got := appDbEnvCreatePath("app_x"); got != "/open-apis/spark/v1/apps/app_x/db_dev_init" {
|
||||
t.Fatalf("appDbEnvCreatePath = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAppID_BlankRejected(t *testing.T) {
|
||||
if _, err := requireAppID(" "); err == nil {
|
||||
t.Fatal("expected error for blank app-id")
|
||||
}
|
||||
got, err := requireAppID(" app_x ")
|
||||
if err != nil || got != "app_x" {
|
||||
t.Fatalf("requireAppID trimmed = %q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
552
shortcuts/apps/git_credential.go
Normal file
552
shortcuts/apps/git_credential.go
Normal file
@@ -0,0 +1,552 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/apps/gitcred"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info"
|
||||
|
||||
// gitCredentialIssueHint is the actionable next-step attached to a failed
|
||||
// Git-credential issuance. A 5xx is flagged retryable separately at the call site.
|
||||
const gitCredentialIssueHint = "failed to issue the Git credential: verify --app-id is correct and you have developer access to this Miaoda app; a 5xx is a transient server error and is safe to retry"
|
||||
|
||||
var AppsGitCredentialInit = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+git-credential-init",
|
||||
Description: "Initialize Git credentials and a URL-scoped Git helper for a Miaoda app repository",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +git-credential-init --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(gitCredentialIssuePath).
|
||||
Desc("Issue a Miaoda Git repository PAT").
|
||||
Set("app_id", appID).
|
||||
Params(gitCredentialIssueParams(appID))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, runtimeIssuer{rctx: rctx})
|
||||
result, err := manager.Init(ctx, profileFromConfig(rctx.Config), appID)
|
||||
if err != nil {
|
||||
return gitCredentialLocalError("Initialize local Miaoda Git credential", err)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"app_id": result.AppID,
|
||||
"repository_url": result.GitHTTPURL,
|
||||
"status": initStatus(result),
|
||||
}
|
||||
if result.ConfigWarning != "" {
|
||||
payload["git_config_warning"] = result.ConfigWarning
|
||||
}
|
||||
rctx.OutFormat(payload, nil, func(w io.Writer) {
|
||||
title := "Git credential initialized"
|
||||
if result.Refreshed {
|
||||
title = "Git credential refreshed"
|
||||
}
|
||||
fmt.Fprintln(w, title)
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "App ID: %s\n", result.AppID)
|
||||
fmt.Fprintf(w, "Status: %s\n", initStatus(result))
|
||||
fmt.Fprintf(w, "Repository URL: %s\n", result.GitHTTPURL)
|
||||
if result.ConfigWarning != "" {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "Git credential saved, but Git helper was not configured")
|
||||
fmt.Fprintf(w, "Reason: %s\n", result.ConfigWarning)
|
||||
fmt.Fprintf(w, "Next step: lark-cli apps +git-credential-init --app-id %s\n", result.AppID)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "Next step:")
|
||||
fmt.Fprintf(w, " git clone %s\n", result.GitHTTPURL)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var AppsGitCredentialRemove = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+git-credential-remove",
|
||||
Description: "Remove local Git credentials and the URL-scoped Git helper for a Miaoda app repository",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +git-credential-remove --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, nil)
|
||||
result, err := manager.Remove(ctx, profileFromConfig(rctx.Config), appID)
|
||||
if err != nil {
|
||||
return gitCredentialLocalError("Remove local Miaoda Git credential", err)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"app_id": result.AppID,
|
||||
"removed": result.Removed,
|
||||
}
|
||||
if result.ConfigWarning != "" {
|
||||
payload["git_config_warning"] = result.ConfigWarning
|
||||
}
|
||||
rctx.OutFormat(payload, nil, func(w io.Writer) {
|
||||
if !result.Removed {
|
||||
fmt.Fprintln(w, "No local Git credential found")
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "Git credential removed")
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "App ID: %s\n", result.AppID)
|
||||
if len(result.Records) > 0 {
|
||||
fmt.Fprintf(w, "Repository URL: %s\n", result.Records[0].GitHTTPURL)
|
||||
}
|
||||
fmt.Fprintln(w, "Status: removed")
|
||||
if result.ConfigWarning != "" {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "Git config cleanup warning")
|
||||
fmt.Fprintf(w, "Reason: %s\n", result.ConfigWarning)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var AppsGitCredentialList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+git-credential-list",
|
||||
Description: "List local Git credentials for Miaoda app repositories",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +git-credential-list",
|
||||
},
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now)
|
||||
if err != nil {
|
||||
return gitCredentialLocalError("List local Miaoda Git credentials", err)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"count": len(records),
|
||||
"credentials": gitCredentialListPayload(records),
|
||||
}
|
||||
rctx.OutFormat(payload, nil, func(w io.Writer) {
|
||||
if len(records) == 0 {
|
||||
fmt.Fprintln(w, "No Git credentials initialized")
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "Next step: lark-cli apps +git-credential-init --app-id <app_id>")
|
||||
return
|
||||
}
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "App ID\tRepository URL\tStatus")
|
||||
for _, record := range records {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n", record.AppID, record.GitHTTPURL, gitCredentialDisplayStatus(record.Status))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "Profile switches do not remove old URL-scoped Git helpers automatically.")
|
||||
fmt.Fprintln(w, "Cleanup: lark-cli apps +git-credential-remove --app-id <app_id>")
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// InstallOnApps attaches hidden, apps-domain commands that are not regular
|
||||
// shortcuts. git-credential-helper must speak Git's stdin/stdout protocol
|
||||
// directly, so it intentionally does not use the shortcut JSON envelope.
|
||||
func InstallOnApps(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
parent.AddCommand(newGitCredentialHelperCommand(f))
|
||||
}
|
||||
|
||||
func newGitCredentialHelperCommand(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "git-credential-helper get|store|erase",
|
||||
Short: "Git credential helper for Miaoda app repositories",
|
||||
Hidden: true,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
appID, _ := cmd.Flags().GetString("app-id")
|
||||
return runGitCredentialHelper(cmd.Context(), f, strings.TrimSpace(appID), args[0])
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("app-id", "", "Miaoda app ID")
|
||||
_ = cmd.Flags().MarkHidden("app-id")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type runtimeIssuer struct {
|
||||
rctx *common.RuntimeContext
|
||||
}
|
||||
|
||||
func (i runtimeIssuer) Issue(ctx context.Context, appID string, profile gitcred.ProfileContext) (*gitcred.IssuedCredential, error) {
|
||||
resp, err := i.rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: issuePath(appID),
|
||||
})
|
||||
data, err := parseIssueCredentialData(resp, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return issuedFromData(appID, data)
|
||||
}
|
||||
|
||||
type factoryIssuer struct {
|
||||
f *cmdutil.Factory
|
||||
}
|
||||
|
||||
func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.ProfileContext) (*gitcred.IssuedCredential, error) {
|
||||
cfg, err := i.f.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.UserOpenId == "" {
|
||||
return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`")
|
||||
}
|
||||
ac, err := i.f.NewAPIClientWithConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: issuePath(appID),
|
||||
}
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
|
||||
data, err := parseIssueCredentialData(resp, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return issuedFromData(appID, data)
|
||||
}
|
||||
|
||||
func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, action string) error {
|
||||
if f == nil || f.IOStreams == nil {
|
||||
return nil
|
||||
}
|
||||
if appID == "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Git credential unavailable: missing app_id; rerun lark-cli apps +git-credential-init --app-id <app_id>")
|
||||
return nil
|
||||
}
|
||||
manager := newGitCredentialManager(appID, f.Keychain, factoryIssuer{f: f})
|
||||
switch action {
|
||||
case "get":
|
||||
input, err := gitcred.ParseCredentialInput(f.IOStreams.In)
|
||||
if err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
return manager.Get(ctx, input, profileFromConfig(cfg), f.IOStreams.Out, f.IOStreams.ErrOut)
|
||||
case "store":
|
||||
return manager.StoreCredential(f.IOStreams.In)
|
||||
case "erase":
|
||||
return manager.Erase(f.IOStreams.In)
|
||||
default:
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "unsupported git credential action %q\n", action)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newGitCredentialManager(appID string, kc keychain.KeychainAccess, issuer gitcred.Issuer) *gitcred.Manager {
|
||||
storage := gitCredentialAppStorage{}
|
||||
return gitcred.NewManager(gitcred.NewAppStore(appID, storage), gitcred.NewSecretStore(kc), gitcred.GlobalGitConfig{}, issuer)
|
||||
}
|
||||
|
||||
func listGitCredentialRecords(kc keychain.KeychainAccess, now func() time.Time) ([]gitcred.ListRecord, error) {
|
||||
storage := gitCredentialAppStorage{}
|
||||
appIDs, err := storage.ListAppIDs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records := make([]gitcred.ListRecord, 0, len(appIDs))
|
||||
for _, appID := range appIDs {
|
||||
manager := newGitCredentialManager(appID, kc, nil)
|
||||
manager.Now = now
|
||||
result, err := manager.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, result.Records...)
|
||||
}
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
if records[i].AppID == records[j].AppID {
|
||||
return records[i].GitHTTPURL < records[j].GitHTTPURL
|
||||
}
|
||||
return records[i].AppID < records[j].AppID
|
||||
})
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func gitCredentialListPayload(records []gitcred.ListRecord) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(records))
|
||||
for _, record := range records {
|
||||
out = append(out, map[string]interface{}{
|
||||
"app_id": record.AppID,
|
||||
"repository_url": record.GitHTTPURL,
|
||||
"status": gitCredentialDisplayStatus(record.Status),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func gitCredentialDisplayStatus(status string) string {
|
||||
if status == gitcred.ListStatusExpired {
|
||||
return "refresh_required"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func profileFromConfig(cfg *core.CliConfig) gitcred.ProfileContext {
|
||||
if cfg == nil {
|
||||
return gitcred.ProfileContext{}
|
||||
}
|
||||
return gitcred.ProfileContext{
|
||||
Profile: cfg.ProfileName,
|
||||
ProfileAppID: cfg.AppID,
|
||||
UserOpenID: cfg.UserOpenId,
|
||||
}
|
||||
}
|
||||
|
||||
func issuePath(appID string) string {
|
||||
return strings.Replace(gitCredentialIssuePath, ":app_id", url.PathEscape(strings.TrimSpace(appID)), 1)
|
||||
}
|
||||
|
||||
func gitCredentialIssueParams(appID string) map[string]interface{} {
|
||||
return map[string]interface{}{"app_id": strings.TrimSpace(appID)}
|
||||
}
|
||||
|
||||
func initStatus(result *gitcred.InitResult) string {
|
||||
if result != nil && result.Refreshed {
|
||||
return "refreshed"
|
||||
}
|
||||
return "initialized"
|
||||
}
|
||||
|
||||
func gitCredentialLocalError(action string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := errs.UnwrapTypedError(err); ok {
|
||||
return err
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: fmt.Sprintf("%s: %s", action, err),
|
||||
Hint: "retry the command; if the local Git credential state is damaged, rerun `lark-cli apps +git-credential-init --app-id <app_id>` or remove the app credential again",
|
||||
}, Cause: err}
|
||||
}
|
||||
|
||||
func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedCredential, error) {
|
||||
source := data
|
||||
for _, key := range []string{"credential", "git_credential", "gitInfo", "git_info"} {
|
||||
if nested, ok := data[key].(map[string]interface{}); ok {
|
||||
source = nested
|
||||
break
|
||||
}
|
||||
}
|
||||
issued := &gitcred.IssuedCredential{
|
||||
AppID: firstString(source, "app_id", appID),
|
||||
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
|
||||
Username: firstString(source, "username"),
|
||||
PAT: firstString(source, "token", "Token", "pat", "password"),
|
||||
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
|
||||
}
|
||||
if issued.AppID == "" {
|
||||
issued.AppID = appID
|
||||
}
|
||||
if issued.GitHTTPURL == "" {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL")
|
||||
}
|
||||
if issued.PAT == "" {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token")
|
||||
}
|
||||
return issued, nil
|
||||
}
|
||||
|
||||
func parseIssueCredentialData(resp *larkcore.ApiResp, err error) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail := logIDDetail(resp)
|
||||
if resp == nil || len(resp.RawBody) == 0 {
|
||||
return nil, &errs.InternalError{Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Message: "Issue Miaoda Git credential: empty response body",
|
||||
}}
|
||||
}
|
||||
var result map[string]any
|
||||
if jsonErr := json.Unmarshal(resp.RawBody, &result); jsonErr != nil {
|
||||
return nil, &errs.InternalError{Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Message: fmt.Sprintf("Issue Miaoda Git credential: unmarshal response: %s", jsonErr),
|
||||
}, Cause: jsonErr}
|
||||
}
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
msg := firstString(result, "msg", "message")
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil, &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Code: resp.StatusCode,
|
||||
Message: msg,
|
||||
LogID: logIDString(resp),
|
||||
Hint: gitCredentialIssueHint,
|
||||
Retryable: resp.StatusCode >= http.StatusInternalServerError,
|
||||
}}
|
||||
}
|
||||
if _, hasCode := result["code"]; hasCode {
|
||||
code := firstInt64(result, "code")
|
||||
if code != 0 {
|
||||
return nil, &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Code: int(code),
|
||||
Message: firstString(result, "msg", "message"),
|
||||
LogID: logIDString(resp),
|
||||
Hint: gitCredentialIssueHint,
|
||||
}}
|
||||
}
|
||||
if data, ok := result["data"].(map[string]any); ok {
|
||||
result = data
|
||||
}
|
||||
} else if err := checkGitInfoBaseResp(result, logIDString(resp)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if detail != nil {
|
||||
if result == nil {
|
||||
result = map[string]any{}
|
||||
}
|
||||
for k, v := range detail {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func checkGitInfoBaseResp(result map[string]any, logID string) error {
|
||||
for _, key := range []string{"BaseResp", "baseResp", "base_resp"} {
|
||||
baseResp, ok := result[key].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
code := firstInt64(baseResp, "StatusCode", "statusCode", "status_code")
|
||||
if code == 0 {
|
||||
return nil
|
||||
}
|
||||
message := firstString(baseResp, "StatusMessage", "statusMessage", "status_message")
|
||||
if message == "" {
|
||||
message = "Git credential API returned non-zero BaseResp status"
|
||||
}
|
||||
return &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Code: int(code),
|
||||
Message: "Issue Miaoda Git credential: " + message,
|
||||
LogID: logID,
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func logIDDetail(resp *larkcore.ApiResp) map[string]any {
|
||||
logID := logIDString(resp)
|
||||
if logID == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"log_id": logID}
|
||||
}
|
||||
|
||||
func logIDString(resp *larkcore.ApiResp) string {
|
||||
if resp == nil {
|
||||
return ""
|
||||
}
|
||||
return resp.Header.Get("x-tt-logid")
|
||||
}
|
||||
|
||||
func firstString(data map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if v, ok := data[key].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstInt64(data map[string]interface{}, keys ...string) int64 {
|
||||
for _, key := range keys {
|
||||
switch v := data[key].(type) {
|
||||
case int64:
|
||||
return v
|
||||
case int:
|
||||
return int64(v)
|
||||
case float64:
|
||||
return int64(v)
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
55
shortcuts/apps/git_credential_storage.go
Normal file
55
shortcuts/apps/git_credential_storage.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // Git credential list scans CLI config-dir state; it is not user file I/O.
|
||||
)
|
||||
|
||||
type gitCredentialAppStorage struct{}
|
||||
|
||||
func (gitCredentialAppStorage) Read(appID, key string) ([]byte, error) {
|
||||
return Read(appID, key)
|
||||
}
|
||||
|
||||
func (gitCredentialAppStorage) Write(appID, key string, data []byte) error {
|
||||
return Write(appID, key, data)
|
||||
}
|
||||
|
||||
func (gitCredentialAppStorage) Delete(appID, key string) error {
|
||||
return Delete(appID, key)
|
||||
}
|
||||
|
||||
func (gitCredentialAppStorage) ListAppIDs() ([]string, error) {
|
||||
root := filepath.Join(core.GetConfigDir(), storageRoot)
|
||||
entries, err := vfs.ReadDir(root)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("apps storage: read root: %w", err)
|
||||
}
|
||||
appIDs := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
appID, err := url.PathUnescape(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := checkSeg(appID, "appID"); err != nil {
|
||||
continue
|
||||
}
|
||||
appIDs = append(appIDs, appID)
|
||||
}
|
||||
return appIDs, nil
|
||||
}
|
||||
1022
shortcuts/apps/git_credential_test.go
Normal file
1022
shortcuts/apps/git_credential_test.go
Normal file
File diff suppressed because it is too large
Load Diff
138
shortcuts/apps/gitcred/gitconfig.go
Normal file
138
shortcuts/apps/gitcred/gitconfig.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type GitConfig interface {
|
||||
SetHelper(ctx context.Context, gitHTTPURL, appID string) error
|
||||
UnsetHelper(ctx context.Context, gitHTTPURL string) error
|
||||
}
|
||||
|
||||
type GlobalGitConfig struct {
|
||||
HelperCommand string
|
||||
}
|
||||
|
||||
func (g GlobalGitConfig) SetHelper(ctx context.Context, gitHTTPURL, appID string) error {
|
||||
normalizedURL, err := NormalizeGitHTTPURL(gitHTTPURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appID = strings.TrimSpace(appID)
|
||||
if err := validate.ResourceName(appID, "appID"); err != nil {
|
||||
return err
|
||||
}
|
||||
helper := g.helperCommand(appID)
|
||||
helperKey := gitCredentialKey(normalizedURL, "helper")
|
||||
useHTTPPathKey := gitCredentialKey(normalizedURL, "useHttpPath")
|
||||
previousHelper, hadHelper, err := gitConfigGet(ctx, helperKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hadHelper && previousHelper != helper && !g.isManagedHelper(previousHelper) {
|
||||
return fmt.Errorf("git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL)
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "git", "config", "--global", helperKey, helper).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "git", "config", "--global", useHTTPPathKey, "true").Run(); err != nil {
|
||||
if !hadHelper {
|
||||
_ = exec.CommandContext(ctx, "git", "config", "--global", "--unset", helperKey).Run()
|
||||
} else if previousHelper != helper {
|
||||
_ = exec.CommandContext(ctx, "git", "config", "--global", helperKey, previousHelper).Run()
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g GlobalGitConfig) UnsetHelper(ctx context.Context, gitHTTPURL string) error {
|
||||
normalizedURL, err := NormalizeGitHTTPURL(gitHTTPURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
helperKey := gitCredentialKey(normalizedURL, "helper")
|
||||
useHTTPPathKey := gitCredentialKey(normalizedURL, "useHttpPath")
|
||||
helper, found, err := gitConfigGet(ctx, helperKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
if !g.isManagedHelper(helper) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := gitConfigUnset(ctx, helperKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gitConfigUnset(ctx, useHTTPPathKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g GlobalGitConfig) helperCommand(appID string) string {
|
||||
if g.HelperCommand != "" {
|
||||
return g.HelperCommand
|
||||
}
|
||||
return "!lark-cli apps git-credential-helper --app-id " + shellQuoteArg(appID)
|
||||
}
|
||||
|
||||
func (g GlobalGitConfig) isManagedHelper(helper string) bool {
|
||||
helper = strings.TrimSpace(helper)
|
||||
if g.HelperCommand != "" {
|
||||
return helper == g.HelperCommand
|
||||
}
|
||||
return strings.HasPrefix(helper, "!lark-cli apps git-credential-helper ")
|
||||
}
|
||||
|
||||
func gitCredentialKey(gitHTTPURL, name string) string {
|
||||
return "credential." + gitHTTPURL + "." + name
|
||||
}
|
||||
|
||||
func gitConfigGet(ctx context.Context, key string) (string, bool, error) {
|
||||
out, err := exec.CommandContext(ctx, "git", "config", "--global", "--get", key).Output()
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(out)), true, nil
|
||||
}
|
||||
if isGitConfigGetMissing(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("get %s: %w", key, err)
|
||||
}
|
||||
|
||||
func gitConfigUnset(ctx context.Context, key string) error {
|
||||
if err := exec.CommandContext(ctx, "git", "config", "--global", "--unset", key).Run(); err != nil {
|
||||
if isGitConfigUnsetMissing(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unset %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isGitConfigGetMissing(err error) bool {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isGitConfigUnsetMissing(err error) bool {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 5 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shellQuoteArg(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
2381
shortcuts/apps/gitcred/gitcred_test.go
Normal file
2381
shortcuts/apps/gitcred/gitcred_test.go
Normal file
File diff suppressed because it is too large
Load Diff
475
shortcuts/apps/gitcred/helper.go
Normal file
475
shortcuts/apps/gitcred/helper.go
Normal file
@@ -0,0 +1,475 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type Issuer interface {
|
||||
Issue(ctx context.Context, appID string, profile ProfileContext) (*IssuedCredential, error)
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
Store *Store
|
||||
Secrets *SecretStore
|
||||
GitConfig GitConfig
|
||||
Issuer Issuer
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer Issuer) *Manager {
|
||||
return &Manager{
|
||||
Store: store,
|
||||
Secrets: secrets,
|
||||
GitConfig: gitConfig,
|
||||
Issuer: issuer,
|
||||
Now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string) (*InitResult, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if err := validate.ResourceName(appID, "--app-id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile.UserOpenID == "" {
|
||||
return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`")
|
||||
}
|
||||
unlockApp, err := lockApp(appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err)
|
||||
}
|
||||
defer unlockApp()
|
||||
if m.Issuer == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "git credential issuer is not configured")
|
||||
}
|
||||
issued, err := m.Issuer.Issue(ctx, appID, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url, err := NormalizeGitHTTPURL(issued.GitHTTPURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := m.nowUnix()
|
||||
if err := validateIssuedCredential(appID, url, issued, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref := BuildPATRef(profile, appID)
|
||||
previous, err := m.currentAppRecord(appID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var previousPAT string
|
||||
if previous != nil {
|
||||
previousPAT, _ = m.Secrets.Get(previous.PATRef)
|
||||
}
|
||||
record := CredentialRecord{
|
||||
AppID: appID,
|
||||
GitHTTPURL: url,
|
||||
Profile: profile.Profile,
|
||||
ProfileAppID: profile.ProfileAppID,
|
||||
UserOpenID: profile.UserOpenID,
|
||||
Username: defaultUsername(issued.Username),
|
||||
PATRef: ref,
|
||||
Status: StatusPending,
|
||||
ExpiresAt: issued.ExpiresAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := m.Store.Upsert(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.Secrets.Set(ref, issued.PAT); err != nil {
|
||||
m.restoreAfterInitFailure(appID, previous, previousPAT)
|
||||
return nil, err
|
||||
}
|
||||
record.Status = StatusConfirmed
|
||||
if err := m.Store.Upsert(record); err != nil {
|
||||
if previous != nil && previous.PATRef == ref && previousPAT != "" {
|
||||
_ = m.Secrets.Set(ref, previousPAT)
|
||||
} else {
|
||||
_ = m.Secrets.Remove(ref)
|
||||
}
|
||||
m.restoreAfterInitFailure(appID, previous, previousPAT)
|
||||
return nil, err
|
||||
}
|
||||
if previous != nil && previous.PATRef != "" && previous.PATRef != ref {
|
||||
_ = m.Secrets.Remove(previous.PATRef)
|
||||
}
|
||||
result := &InitResult{AppID: appID, GitHTTPURL: url, Refreshed: previous != nil}
|
||||
if m.GitConfig != nil {
|
||||
if err := m.GitConfig.SetHelper(ctx, url, appID); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
} else if previous != nil && previous.GitHTTPURL != "" && previous.GitHTTPURL != url {
|
||||
if err := m.GitConfig.UnsetHelper(ctx, previous.GitHTTPURL); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Remove(ctx context.Context, profile ProfileContext, appID string) (*RemoveResult, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if err := validate.ResourceName(appID, "--app-id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unlockApp, err := lockApp(appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err)
|
||||
}
|
||||
defer unlockApp()
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &RemoveResult{AppID: appID, Records: records}
|
||||
for _, record := range records {
|
||||
if err := m.Secrets.Remove(record.PATRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.GitConfig != nil {
|
||||
if err := m.GitConfig.UnsetHelper(ctx, record.GitHTTPURL); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
}
|
||||
}
|
||||
if _, err := m.Store.DeleteByURL(record.GitHTTPURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Removed = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) List() (*ListResult, error) {
|
||||
records, err := m.Store.Records()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ListRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
out = append(out, m.listRecord(record))
|
||||
}
|
||||
return &ListResult{Records: out}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Get(ctx context.Context, input CredentialInput, current ProfileContext, out, errOut io.Writer) error {
|
||||
url, err := NormalizeCredentialInput(input)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
record, pat, ok, err := m.readConfirmed(url, current)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if m.usable(record, pat) {
|
||||
return writeGitCredential(out, record.Username, pat)
|
||||
}
|
||||
|
||||
unlock := lockURL(url)
|
||||
defer unlock()
|
||||
unlockApp, err := lockApp(record.AppID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: acquire lock for %s: %s\n", record.AppID, err)
|
||||
return nil
|
||||
}
|
||||
defer unlockApp()
|
||||
|
||||
record, pat, ok, err = m.readConfirmed(url, current)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if m.usable(record, pat) {
|
||||
return writeGitCredential(out, record.Username, pat)
|
||||
}
|
||||
if m.Issuer == nil {
|
||||
fmt.Fprintln(errOut, "Git credential refresh failed: issuer is not configured")
|
||||
return nil
|
||||
}
|
||||
issued, err := m.Issuer.Issue(ctx, record.AppID, current)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\nNext step: lark-cli apps +git-credential-init --app-id %s\n", err, record.AppID)
|
||||
return nil
|
||||
}
|
||||
issuedURL, urlErr := NormalizeGitHTTPURL(issued.GitHTTPURL)
|
||||
if urlErr != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", urlErr)
|
||||
return nil
|
||||
}
|
||||
if err := validateIssuedCredential(record.AppID, issuedURL, issued, m.nowUnix()); err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if issuedURL != url {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: issued repository URL %q does not match initialized URL %q\n", issuedURL, url)
|
||||
return nil
|
||||
}
|
||||
if issued.ExpiresAt < record.ExpiresAt {
|
||||
latest, latestPAT, found, readErr := m.readConfirmed(url, current)
|
||||
if readErr != nil {
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", readErr)
|
||||
return nil
|
||||
}
|
||||
if found && m.usable(latest, latestPAT) {
|
||||
return writeGitCredential(out, latest.Username, latestPAT)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
record.Username = defaultUsername(issued.Username)
|
||||
record.ExpiresAt = issued.ExpiresAt
|
||||
record.UpdatedAt = m.nowUnix()
|
||||
record.InvalidatedAt = 0
|
||||
record.Status = StatusConfirmed
|
||||
oldPAT := pat
|
||||
if err := m.Secrets.Set(record.PATRef, issued.PAT); err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if err := m.Store.Upsert(record); err != nil {
|
||||
_ = m.Secrets.Set(record.PATRef, oldPAT)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
return writeGitCredential(out, record.Username, issued.PAT)
|
||||
}
|
||||
|
||||
func (m *Manager) currentAppRecord(appID string) (*CredentialRecord, error) {
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
if err != nil || len(records) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &records[0], nil
|
||||
}
|
||||
|
||||
func (m *Manager) restoreAfterInitFailure(appID string, existing *CredentialRecord, existingPAT string) {
|
||||
if existing == nil {
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
if err == nil {
|
||||
for _, record := range records {
|
||||
_, _ = m.Store.DeleteByURL(record.GitHTTPURL)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = m.Store.Upsert(*existing)
|
||||
if existingPAT != "" {
|
||||
_ = m.Secrets.Set(existing.PATRef, existingPAT)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) listRecord(record CredentialRecord) ListRecord {
|
||||
now := m.nowUnix()
|
||||
status := ListStatusValid
|
||||
expired := record.ExpiresAt <= now
|
||||
switch {
|
||||
case record.Status != StatusConfirmed || record.GitHTTPURL == "" || record.PATRef == "":
|
||||
status = ListStatusIncomplete
|
||||
case record.InvalidatedAt > 0:
|
||||
status = ListStatusInvalidated
|
||||
case !m.hasSecret(record.PATRef):
|
||||
status = ListStatusMissingSecret
|
||||
case expired:
|
||||
status = ListStatusExpired
|
||||
}
|
||||
return ListRecord{
|
||||
AppID: record.AppID,
|
||||
GitHTTPURL: record.GitHTTPURL,
|
||||
Status: status,
|
||||
ExpiresAt: record.ExpiresAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
Profile: record.Profile,
|
||||
ProfileAppID: record.ProfileAppID,
|
||||
UserOpenID: record.UserOpenID,
|
||||
Expired: expired,
|
||||
InvalidatedAt: record.InvalidatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) hasSecret(ref string) bool {
|
||||
pat, err := m.Secrets.Get(ref)
|
||||
return err == nil && pat != ""
|
||||
}
|
||||
|
||||
func (m *Manager) StoreCredential(r io.Reader) error {
|
||||
_, err := io.Copy(io.Discard, r)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) Erase(r io.Reader) error {
|
||||
input, err := ParseCredentialInput(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url, err := NormalizeCredentialInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record, err := m.Store.FindByURL(url)
|
||||
if err != nil || record == nil {
|
||||
return err
|
||||
}
|
||||
unlockApp, err := lockApp(record.AppID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire Git credential lock for %s: %w", record.AppID, err)
|
||||
}
|
||||
defer unlockApp()
|
||||
record, err = m.Store.FindByURL(url)
|
||||
if err != nil || record == nil {
|
||||
return err
|
||||
}
|
||||
now := m.nowUnix()
|
||||
if record.LastEraseAt > 0 && now-record.LastEraseAt < int64(eraseCooldown.Seconds()) {
|
||||
return nil
|
||||
}
|
||||
record.InvalidatedAt = now
|
||||
record.LastEraseAt = now
|
||||
if err := m.Store.Upsert(*record); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Secrets.Remove(record.PATRef)
|
||||
}
|
||||
|
||||
func (m *Manager) readConfirmed(url string, current ProfileContext) (CredentialRecord, string, bool, error) {
|
||||
record, err := m.Store.FindByURL(url)
|
||||
if err != nil || record == nil {
|
||||
return CredentialRecord{}, "", false, err
|
||||
}
|
||||
if record.ProfileAppID != current.ProfileAppID || record.UserOpenID != current.UserOpenID {
|
||||
return CredentialRecord{}, "", false, fmt.Errorf("current login does not match initialized credential; run `lark-cli apps +git-credential-init --app-id %s` with the current login or switch back to the original account", record.AppID)
|
||||
}
|
||||
pat, err := m.Secrets.Get(record.PATRef)
|
||||
if err != nil {
|
||||
pat = ""
|
||||
}
|
||||
return *record, pat, true, nil
|
||||
}
|
||||
|
||||
func (m *Manager) usable(record CredentialRecord, pat string) bool {
|
||||
if record.Status != StatusConfirmed || pat == "" || record.InvalidatedAt > 0 {
|
||||
return false
|
||||
}
|
||||
return record.ExpiresAt-m.nowUnix() > int64(refreshBeforeExpiry.Seconds())
|
||||
}
|
||||
|
||||
func (m *Manager) now() time.Time {
|
||||
if m != nil && m.Now != nil {
|
||||
return m.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (m *Manager) nowUnix() int64 {
|
||||
return m.now().Unix()
|
||||
}
|
||||
|
||||
func ParseCredentialInput(r io.Reader) (CredentialInput, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
var input CredentialInput
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "protocol":
|
||||
input.Protocol = value
|
||||
case "host":
|
||||
input.Host = value
|
||||
case "path":
|
||||
input.Path = value
|
||||
case "url":
|
||||
u, err := NormalizeGitHTTPURL(value)
|
||||
if err == nil {
|
||||
parsed, _ := parseNormalizedForInput(u)
|
||||
input = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return input, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func parseNormalizedForInput(raw string) (CredentialInput, error) {
|
||||
parts := strings.SplitN(raw, "://", 2)
|
||||
if len(parts) != 2 {
|
||||
return CredentialInput{}, output.ErrValidation("invalid credential URL")
|
||||
}
|
||||
hostPath := parts[1]
|
||||
idx := strings.Index(hostPath, "/")
|
||||
if idx < 0 {
|
||||
return CredentialInput{Protocol: parts[0], Host: hostPath, Path: "/"}, nil
|
||||
}
|
||||
return CredentialInput{Protocol: parts[0], Host: hostPath[:idx], Path: hostPath[idx:]}, nil
|
||||
}
|
||||
|
||||
func writeGitCredential(w io.Writer, username, pat string) error {
|
||||
if username == "" || pat == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "username=%s\n", username); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "password=%s\n", pat); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(w)
|
||||
return err
|
||||
}
|
||||
|
||||
func defaultUsername(username string) string {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return "x-access-token"
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
func validateIssuedCredential(appID, normalizedURL string, issued *IssuedCredential, now int64) error {
|
||||
if issued == nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: empty credential")
|
||||
}
|
||||
if issued.AppID != "" && issued.AppID != appID {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
|
||||
}
|
||||
if normalizedURL == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL")
|
||||
}
|
||||
if strings.TrimSpace(issued.PAT) == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token")
|
||||
}
|
||||
if issued.ExpiresAt <= now {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response expiredTime must be in the future")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
72
shortcuts/apps/gitcred/keychain.go
Normal file
72
shortcuts/apps/gitcred/keychain.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
type SecretStore struct {
|
||||
kc keychain.KeychainAccess
|
||||
}
|
||||
|
||||
func NewSecretStore(kc keychain.KeychainAccess) *SecretStore {
|
||||
return &SecretStore{kc: kc}
|
||||
}
|
||||
|
||||
func (s *SecretStore) Get(ref string) (string, error) {
|
||||
if s == nil || ref == "" {
|
||||
return "", nil
|
||||
}
|
||||
if s.kc == nil {
|
||||
return "", nil
|
||||
}
|
||||
return s.kc.Get(KeychainService, ref)
|
||||
}
|
||||
|
||||
func (s *SecretStore) Set(ref, pat string) error {
|
||||
if s == nil || s.kc == nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "local keychain is unavailable",
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-init",
|
||||
}}
|
||||
}
|
||||
if ref == "" {
|
||||
return &errs.InternalError{Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Message: "keychain PAT reference is empty",
|
||||
}}
|
||||
}
|
||||
return s.kc.Set(KeychainService, ref, pat)
|
||||
}
|
||||
|
||||
func (s *SecretStore) Remove(ref string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if ref == "" {
|
||||
return nil
|
||||
}
|
||||
if s.kc == nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "local keychain is unavailable",
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove",
|
||||
}}
|
||||
}
|
||||
if err := s.kc.Remove(KeychainService, ref); err != nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "remove local Git credential PAT from keychain failed: " + err.Error(),
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove",
|
||||
}, Cause: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
shortcuts/apps/gitcred/lock.go
Normal file
48
shortcuts/apps/gitcred/lock.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // git credential locks live under CLI config dir and are not user file I/O.
|
||||
)
|
||||
|
||||
var urlLocks sync.Map
|
||||
|
||||
var safeLockNameChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
func lockURL(url string) func() {
|
||||
actual, _ := urlLocks.LoadOrStore(url, &sync.Mutex{})
|
||||
mu := actual.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
func lockApp(appID string) (func(), error) {
|
||||
dir := filepath.Join(core.GetConfigDir(), "locks")
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("create Git credential lock dir: %w", err)
|
||||
}
|
||||
name := "apps_git_credential_" + safeLockNameChars.ReplaceAllString(appID, "_") + ".lock"
|
||||
lock := lockfile.New(filepath.Join(dir, filepath.Base(name)))
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
err := lock.TryLock()
|
||||
if err == nil {
|
||||
return func() { _ = lock.Unlock() }, nil
|
||||
}
|
||||
if !errors.Is(err, lockfile.ErrHeld) || time.Now().After(deadline) {
|
||||
return nil, err
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
200
shortcuts/apps/gitcred/store.go
Normal file
200
shortcuts/apps/gitcred/store.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // git credential metadata is CLI config-dir state, not user file I/O.
|
||||
)
|
||||
|
||||
type AppStorage interface {
|
||||
Read(appID, key string) ([]byte, error)
|
||||
Write(appID, key string, data []byte) error
|
||||
Delete(appID, key string) error
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
appID string
|
||||
storage AppStorage
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{path: filepath.Join(core.GetConfigDir(), MetadataFilename)}
|
||||
}
|
||||
|
||||
func NewAppStore(appID string, storage AppStorage) *Store {
|
||||
return &Store{appID: appID, storage: storage}
|
||||
}
|
||||
|
||||
func NewStoreAt(path string) *Store {
|
||||
return &Store{path: path}
|
||||
}
|
||||
|
||||
func (s *Store) Path() string {
|
||||
if s.storage != nil {
|
||||
return fmt.Sprintf("apps:%s/%s", s.appID, MetadataFilename)
|
||||
}
|
||||
return s.path
|
||||
}
|
||||
|
||||
func (s *Store) Load() (*CredentialFile, error) {
|
||||
data, err := s.read()
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return &CredentialFile{Version: CurrentCredentialVersion}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return &CredentialFile{Version: CurrentCredentialVersion}, nil
|
||||
}
|
||||
var file CredentialFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "invalid %s: %s", MetadataFilename, err).
|
||||
WithHint("the local Git credential metadata is damaged; rerun `lark-cli apps +git-credential-init --app-id <app_id>` after backing up or removing the damaged app metadata").
|
||||
WithCause(err)
|
||||
}
|
||||
if file.Version == 0 {
|
||||
file.Version = CurrentCredentialVersion
|
||||
}
|
||||
if file.Version > CurrentCredentialVersion {
|
||||
return nil, &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: fmt.Sprintf("%s version %d is newer than supported version %d", MetadataFilename, file.Version, CurrentCredentialVersion),
|
||||
Hint: "upgrade lark-cli and retry",
|
||||
}}
|
||||
}
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
func (s *Store) Save(file *CredentialFile) error {
|
||||
if file == nil {
|
||||
file = &CredentialFile{}
|
||||
}
|
||||
file.Version = CurrentCredentialVersion
|
||||
data, _ := json.MarshalIndent(file, "", " ")
|
||||
return s.write(append(data, '\n'))
|
||||
}
|
||||
|
||||
func (s *Store) Upsert(record CredentialRecord) error {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file.CredentialRecord = record
|
||||
return s.Save(file)
|
||||
}
|
||||
|
||||
func (s *Store) Current() (*CredentialRecord, error) {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file.GitHTTPURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &file.CredentialRecord, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteByURL(gitHTTPURL string) (*CredentialRecord, error) {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file.GitHTTPURL != gitHTTPURL || file.GitHTTPURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
record := file.CredentialRecord
|
||||
if err := s.delete(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindByURL(gitHTTPURL string) (*CredentialRecord, error) {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file.GitHTTPURL != gitHTTPURL || file.GitHTTPURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &file.CredentialRecord, nil
|
||||
}
|
||||
|
||||
func (s *Store) Records() ([]CredentialRecord, error) {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file.GitHTTPURL == "" {
|
||||
return []CredentialRecord{}, nil
|
||||
}
|
||||
return []CredentialRecord{file.CredentialRecord}, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindByAppID(appID string, profile ProfileContext) ([]CredentialRecord, error) {
|
||||
records, err := s.Records()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]CredentialRecord, 0)
|
||||
for _, record := range records {
|
||||
if record.AppID != appID {
|
||||
continue
|
||||
}
|
||||
if profile.Profile != "" && record.Profile != profile.Profile {
|
||||
continue
|
||||
}
|
||||
if profile.ProfileAppID != "" && record.ProfileAppID != profile.ProfileAppID {
|
||||
continue
|
||||
}
|
||||
if profile.UserOpenID != "" && record.UserOpenID != profile.UserOpenID {
|
||||
continue
|
||||
}
|
||||
out = append(out, record)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) read() ([]byte, error) {
|
||||
if s.storage != nil {
|
||||
data, err := s.storage.Read(s.appID, MetadataFilename)
|
||||
if data == nil && err == nil {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
return vfs.ReadFile(s.path)
|
||||
}
|
||||
|
||||
func (s *Store) write(data []byte) error {
|
||||
if s.storage != nil {
|
||||
return s.storage.Write(s.appID, MetadataFilename, data)
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(s.path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(s.path, data, 0600)
|
||||
}
|
||||
|
||||
func (s *Store) delete() error {
|
||||
if s.storage != nil {
|
||||
return s.storage.Delete(s.appID, MetadataFilename)
|
||||
}
|
||||
if err := vfs.Remove(s.path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
102
shortcuts/apps/gitcred/types.go
Normal file
102
shortcuts/apps/gitcred/types.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
CurrentCredentialVersion = 1
|
||||
MetadataFilename = "git.json"
|
||||
|
||||
// KeychainService intentionally reuses the CLI-wide internal keychain
|
||||
// service, so Git PAT .enc files stay under Application Support/lark-cli.
|
||||
KeychainService = "lark-cli"
|
||||
|
||||
StatusPending = "pending"
|
||||
StatusConfirmed = "confirmed"
|
||||
|
||||
ListStatusValid = "valid"
|
||||
ListStatusExpired = "expired"
|
||||
ListStatusInvalidated = "invalidated"
|
||||
ListStatusMissingSecret = "missing_secret"
|
||||
ListStatusIncomplete = "incomplete"
|
||||
|
||||
refreshBeforeExpiry = 10 * time.Minute
|
||||
eraseCooldown = 5 * time.Minute
|
||||
)
|
||||
|
||||
// CredentialFile is the app-scoped non-secret metadata persisted under the
|
||||
// Miaoda app storage directory.
|
||||
type CredentialFile struct {
|
||||
Version int `json:"version"`
|
||||
CredentialRecord
|
||||
}
|
||||
|
||||
// CredentialRecord points to the keychain-stored PAT without storing the PAT
|
||||
// plaintext in metadata.
|
||||
type CredentialRecord struct {
|
||||
AppID string `json:"app_id"`
|
||||
GitHTTPURL string `json:"git_http_url"`
|
||||
Profile string `json:"profile"`
|
||||
ProfileAppID string `json:"profile_app_id"`
|
||||
UserOpenID string `json:"user_open_id"`
|
||||
Username string `json:"username"`
|
||||
PATRef string `json:"pat_ref"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
LastEraseAt int64 `json:"last_erase_at,omitempty"`
|
||||
InvalidatedAt int64 `json:"invalidated_at,omitempty"`
|
||||
}
|
||||
|
||||
type IssuedCredential struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Username string
|
||||
PAT string
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
type InitResult struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Refreshed bool
|
||||
ConfigWarning string
|
||||
}
|
||||
|
||||
type RemoveResult struct {
|
||||
AppID string
|
||||
Removed bool
|
||||
Records []CredentialRecord
|
||||
ConfigWarning string
|
||||
}
|
||||
|
||||
type ListResult struct {
|
||||
Records []ListRecord
|
||||
}
|
||||
|
||||
type ListRecord struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Status string
|
||||
ExpiresAt int64
|
||||
UpdatedAt int64
|
||||
Profile string
|
||||
ProfileAppID string
|
||||
UserOpenID string
|
||||
Expired bool
|
||||
InvalidatedAt int64
|
||||
}
|
||||
|
||||
type CredentialInput struct {
|
||||
Protocol string
|
||||
Host string
|
||||
Path string
|
||||
}
|
||||
|
||||
type ProfileContext struct {
|
||||
Profile string
|
||||
ProfileAppID string
|
||||
UserOpenID string
|
||||
}
|
||||
114
shortcuts/apps/gitcred/url.go
Normal file
114
shortcuts/apps/gitcred/url.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NormalizeGitHTTPURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", output.ErrValidation("git_http_url is empty")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("invalid git_http_url %q: %s", raw, err)
|
||||
}
|
||||
return normalizeParsedURL(u)
|
||||
}
|
||||
|
||||
func NormalizeCredentialInput(input CredentialInput) (string, error) {
|
||||
protocol := strings.TrimSpace(input.Protocol)
|
||||
host := strings.TrimSpace(input.Host)
|
||||
if protocol == "" || host == "" {
|
||||
return "", output.ErrValidation("git credential input must include protocol and host")
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: protocol,
|
||||
Host: host,
|
||||
Path: input.Path,
|
||||
}
|
||||
return normalizeParsedURL(u)
|
||||
}
|
||||
|
||||
func normalizeParsedURL(u *url.URL) (string, error) {
|
||||
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", output.ErrValidation("git credential only supports http/https URLs")
|
||||
}
|
||||
host := normalizeHost(scheme, u.Host)
|
||||
if host == "" {
|
||||
return "", output.ErrValidation("git_http_url host is empty")
|
||||
}
|
||||
cleanPath := cleanURLPath(u.EscapedPath())
|
||||
normalized := (&url.URL{Scheme: scheme, Host: host, Path: cleanPath}).String()
|
||||
if normalized != scheme+"://"+host+"/" {
|
||||
normalized = strings.TrimRight(normalized, "/")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeHost(scheme, host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
name, port, err := net.SplitHostPort(host)
|
||||
if err == nil {
|
||||
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
|
||||
return normalizeHostname(name)
|
||||
}
|
||||
return net.JoinHostPort(strings.ToLower(name), port)
|
||||
}
|
||||
return normalizeHostname(host)
|
||||
}
|
||||
|
||||
func normalizeHostname(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
name := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
||||
if ip := net.ParseIP(name); ip != nil && ip.To4() == nil {
|
||||
return joinHostWithoutPort(name)
|
||||
}
|
||||
return host
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
|
||||
return joinHostWithoutPort(host)
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func joinHostWithoutPort(host string) string {
|
||||
joined := net.JoinHostPort(host, "")
|
||||
return strings.TrimSuffix(joined, ":")
|
||||
}
|
||||
|
||||
func cleanURLPath(rawPath string) string {
|
||||
if rawPath == "" {
|
||||
return "/"
|
||||
}
|
||||
decoded, err := url.PathUnescape(rawPath)
|
||||
if err != nil {
|
||||
decoded = rawPath
|
||||
}
|
||||
if !strings.HasPrefix(decoded, "/") {
|
||||
decoded = "/" + decoded
|
||||
}
|
||||
return path.Clean(decoded)
|
||||
}
|
||||
|
||||
func BuildPATRef(profile ProfileContext, appID string) string {
|
||||
seed := fmt.Sprintf("%s\x00%s", profile.UserOpenID, appID)
|
||||
sum := sha256.Sum256([]byte(seed))
|
||||
return "app-git-pat:" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
@@ -137,3 +137,9 @@ func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing
|
||||
t.Fatalf("hint should reference app_id, got: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTMLPublishResponse_InvalidJSON(t *testing.T) {
|
||||
if _, err := parseHTMLPublishResponse([]byte("{not json")); err == nil {
|
||||
t.Error("malformed html-publish response must surface a decode error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,22 @@ func Shortcuts() []common.Shortcut {
|
||||
AppsAccessScopeSet,
|
||||
AppsAccessScopeGet,
|
||||
AppsHTMLPublish,
|
||||
AppsInit,
|
||||
AppsReleaseCreate,
|
||||
AppsReleaseList,
|
||||
AppsReleaseGet,
|
||||
AppsEnvPull,
|
||||
AppsDBTableList,
|
||||
AppsDBTableGet,
|
||||
AppsDBExecute,
|
||||
AppsDBEnvCreate,
|
||||
AppsGitCredentialInit,
|
||||
AppsGitCredentialList,
|
||||
AppsGitCredentialRemove,
|
||||
AppsSessionCreate,
|
||||
AppsSessionList,
|
||||
AppsSessionGet,
|
||||
AppsSessionStop,
|
||||
AppsChat,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,77 @@
|
||||
|
||||
package apps
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。
|
||||
func TestAppsShortcuts_Returns6(t *testing.T) {
|
||||
// 6 基础 + 1 init + 3 publish + 1 env-pull + 4 db(table-list/table-schema/sql/dev-init)
|
||||
// + 3 git-credential + 5 session(create/list/get/stop/chat)= 23。
|
||||
func TestAppsShortcuts_Returns23(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
if len(got) != 6 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 6", len(got))
|
||||
if len(got) != 23 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 23", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 确认 5 个 session 生命周期命令都已挂载。
|
||||
func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) {
|
||||
want := map[string]bool{
|
||||
"+session-create": false,
|
||||
"+session-list": false,
|
||||
"+session-get": false,
|
||||
"+session-stop": false,
|
||||
"+chat": false,
|
||||
}
|
||||
for _, sc := range Shortcuts() {
|
||||
if _, ok := want[sc.Command]; ok {
|
||||
want[sc.Command] = true
|
||||
}
|
||||
}
|
||||
for cmd, found := range want {
|
||||
if !found {
|
||||
t.Errorf("Shortcuts() missing %s", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Command == "git-credential-helper" {
|
||||
t.Fatalf("git credential helper must be installed as a hidden apps command, not as a shortcut")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsGitCredentialRemove_IsLocalCleanupWithoutScopes(t *testing.T) {
|
||||
if len(AppsGitCredentialRemove.Scopes) != 0 {
|
||||
t.Fatalf("git credential remove scopes = %#v, want none for local cleanup", AppsGitCredentialRemove.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsGitCredentialList_IsLocalReadWithoutScopes(t *testing.T) {
|
||||
if len(AppsGitCredentialList.Scopes) != 0 {
|
||||
t.Fatalf("git credential list scopes = %#v, want none for local read", AppsGitCredentialList.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallOnApps_AddsHiddenGitCredentialHelper(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "apps"}
|
||||
InstallOnApps(parent, nil)
|
||||
cmd, _, err := parent.Find([]string{"git-credential-helper"})
|
||||
if err != nil {
|
||||
t.Fatalf("find helper returned error: %v", err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != "git-credential-helper" {
|
||||
t.Fatalf("helper command not installed: %#v", cmd)
|
||||
}
|
||||
if !cmd.Hidden {
|
||||
t.Fatalf("git credential helper must be hidden")
|
||||
}
|
||||
if cmd.RunE == nil {
|
||||
t.Fatalf("git credential helper must run outside the shortcut pipeline")
|
||||
}
|
||||
}
|
||||
|
||||
133
shortcuts/apps/storage.go
Normal file
133
shortcuts/apps/storage.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // existing apps storage persists CLI config-dir state; it is not user file I/O.
|
||||
)
|
||||
|
||||
// storageRoot is the per-domain local-storage directory name under the config dir.
|
||||
const storageRoot = "spark"
|
||||
|
||||
// checkSeg validates a value used as a single path segment (appID or key).
|
||||
// It rejects empty, "..", "." , URL metacharacters, control and dangerous
|
||||
// Unicode via validate.ResourceName — defense-in-depth alongside the
|
||||
// EncodePathSegment escaping applied when building the path, so neither value
|
||||
// can traverse out of the storage directory.
|
||||
func checkSeg(name, what string) error {
|
||||
if err := validate.ResourceName(name, what); err != nil {
|
||||
return fmt.Errorf("apps storage: %w", err)
|
||||
}
|
||||
if name == "." {
|
||||
return fmt.Errorf("apps storage: %s must not be \".\"", what)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appDir returns the storage directory for one app: ~/.lark-cli/spark/<esc(appID)>/
|
||||
// (workspace-aware).
|
||||
func appDir(appID string) string {
|
||||
return filepath.Join(core.GetConfigDir(), storageRoot, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appKeyPath returns the file path for one (appID, key).
|
||||
func appKeyPath(appID, key string) string {
|
||||
return filepath.Join(appDir(appID), validate.EncodePathSegment(key))
|
||||
}
|
||||
|
||||
// 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 Read(appID, key string) ([]byte, error) {
|
||||
if err := checkSeg(appID, "appID"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkSeg(key, "key"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := vfs.ReadFile(appKeyPath(appID, key))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("apps storage: read: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func Write(appID, key string, data []byte) error {
|
||||
if err := checkSeg(appID, "appID"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkSeg(key, "key"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.MkdirAll(appDir(appID), 0700); err != nil {
|
||||
return fmt.Errorf("apps storage: create dir: %w", err)
|
||||
}
|
||||
if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil {
|
||||
return fmt.Errorf("apps storage: write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the file under (appID, key). A missing file is not an error.
|
||||
func Delete(appID, key string) error {
|
||||
if err := checkSeg(appID, "appID"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkSeg(key, "key"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Remove(appKeyPath(appID, key)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("apps storage: delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 List(appID string) ([]string, error) {
|
||||
if err := checkSeg(appID, "appID"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := vfs.ReadDir(appDir(appID))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("apps storage: read dir: %w", err)
|
||||
}
|
||||
keys := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
key, err := url.PathUnescape(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := checkSeg(key, "key"); err != nil {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
303
shortcuts/apps/storage_test.go
Normal file
303
shortcuts/apps/storage_test.go
Normal file
@@ -0,0 +1,303 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// storageTempDir points GetConfigDir at an isolated temp dir for the test.
|
||||
func storageTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestStorageWriteReadRoundTrip(t *testing.T) {
|
||||
storageTempDir(t)
|
||||
want := []byte(`{"username":"u","token":"t"}`)
|
||||
if err := Write("app_a", "git.json", want); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
got, err := Read("app_a", "git.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageReadMissingReturnsNil(t *testing.T) {
|
||||
storageTempDir(t)
|
||||
got, err := Read("app_a", "nope")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("want nil, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageEmptyArgsRejected(t *testing.T) {
|
||||
storageTempDir(t)
|
||||
if _, err := Read("", "k"); err == nil {
|
||||
t.Error("Read empty appID should error")
|
||||
}
|
||||
if _, err := Read("a", ""); err == nil {
|
||||
t.Error("Read empty key should error")
|
||||
}
|
||||
if err := Write("", "k", nil); err == nil {
|
||||
t.Error("Write empty appID should error")
|
||||
}
|
||||
if err := Write("a", "", nil); err == nil {
|
||||
t.Error("Write empty key should error")
|
||||
}
|
||||
if err := Delete("", "k"); err == nil {
|
||||
t.Error("Delete empty appID should error")
|
||||
}
|
||||
if _, err := List(""); err == nil {
|
||||
t.Error("List empty appID should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageOverwrite(t *testing.T) {
|
||||
storageTempDir(t)
|
||||
if err := Write("app_a", "git.json", []byte("v1")); err != nil {
|
||||
t.Fatalf("Write1: %v", err)
|
||||
}
|
||||
if err := Write("app_a", "git.json", []byte("v2")); err != nil {
|
||||
t.Fatalf("Write2: %v", err)
|
||||
}
|
||||
got, _ := Read("app_a", "git.json")
|
||||
if string(got) != "v2" {
|
||||
t.Errorf("want v2, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageDeleteIdempotent(t *testing.T) {
|
||||
storageTempDir(t)
|
||||
if err := Write("app_a", "git.json", []byte("x")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if err := Delete("app_a", "git.json"); err != nil {
|
||||
t.Fatalf("first Delete: %v", err)
|
||||
}
|
||||
if got, _ := Read("app_a", "git.json"); got != nil {
|
||||
t.Error("file should be gone after Delete")
|
||||
}
|
||||
if err := Delete("app_a", "git.json"); err != nil {
|
||||
t.Errorf("second Delete should be nil (idempotent), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageListKeys(t *testing.T) {
|
||||
storageTempDir(t)
|
||||
for _, k := range []string{"git.json", "meta.json", "notes"} {
|
||||
if err := Write("app_a", k, []byte("x")); err != nil {
|
||||
t.Fatalf("Write %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
got, err := List("app_a")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := []string{"git.json", "meta.json", "notes"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageListMissingAppDir(t *testing.T) {
|
||||
storageTempDir(t)
|
||||
got, err := List("never_written")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("want empty, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageListSkipsSubdirs(t *testing.T) {
|
||||
dir := storageTempDir(t)
|
||||
if err := Write("app_a", "git.json", []byte("x")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "spark", "app_a", "sub"), 0700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
got, err := List("app_a")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "git.json" {
|
||||
t.Errorf("want [git.json], got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageListSkipsInvalidDecodedKeys(t *testing.T) {
|
||||
dir := storageTempDir(t)
|
||||
if err := Write("app_a", "git.json", []byte("x")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
for _, name := range []string{"%zz", "%2E", "%2E%2E", "bad%2F..%2Fkey"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, "spark", "app_a", name), []byte("x"), 0600); err != nil {
|
||||
t.Fatalf("write polluted key %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
got, err := List("app_a")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "git.json" {
|
||||
t.Errorf("want [git.json], got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageEscapesAppIDAndKey(t *testing.T) {
|
||||
dir := storageTempDir(t)
|
||||
const appID, key = "a/b", "x/y"
|
||||
if err := Write(appID, key, []byte("v")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
// no path traversal: spark/ has exactly one (escaped) app dir, no nested a/b tree
|
||||
entries, _ := os.ReadDir(filepath.Join(dir, "spark"))
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 escaped app dir under spark/, got %v", entries)
|
||||
}
|
||||
got, err := Read(appID, key)
|
||||
if err != nil || string(got) != "v" {
|
||||
t.Fatalf("Read escaped: got %q err %v", got, err)
|
||||
}
|
||||
keys, err := List(appID)
|
||||
if err != nil || len(keys) != 1 || keys[0] != key {
|
||||
t.Fatalf("List escaped: got %v err %v", keys, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageRejectsTraversal(t *testing.T) {
|
||||
dir := storageTempDir(t)
|
||||
for _, bad := range []string{"..", ".", "../x", "a/../b"} {
|
||||
if err := Write(bad, "k", []byte("x")); err == nil {
|
||||
t.Errorf("Write appID=%q should error", bad)
|
||||
}
|
||||
if err := Write("app", bad, []byte("x")); err == nil {
|
||||
t.Errorf("Write key=%q should error", bad)
|
||||
}
|
||||
if _, err := Read(bad, "k"); err == nil {
|
||||
t.Errorf("Read appID=%q should error", bad)
|
||||
}
|
||||
if err := Delete(bad, "k"); err == nil {
|
||||
t.Errorf("Delete appID=%q should error", bad)
|
||||
}
|
||||
if _, err := List(bad); err == nil {
|
||||
t.Errorf("List appID=%q should error", bad)
|
||||
}
|
||||
}
|
||||
// nothing escaped out of spark/ into ~/.lark-cli
|
||||
if _, err := os.Stat(filepath.Join(dir, "k")); !os.IsNotExist(err) {
|
||||
t.Error("traversal must not create files outside spark/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageReadNonNotExistError(t *testing.T) {
|
||||
dir := storageTempDir(t)
|
||||
// A directory at the key path makes ReadFile fail with a non-ErrNotExist error.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "spark", "app_a", "git.json"), 0700); err != nil {
|
||||
t.Fatalf("mkdir key path: %v", err)
|
||||
}
|
||||
if _, err := Read("app_a", "git.json"); err == nil {
|
||||
t.Fatal("expected error reading a directory key path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageWriteMkdirError(t *testing.T) {
|
||||
dir := storageTempDir(t)
|
||||
// A file at spark/ makes creating the per-app directory under it fail.
|
||||
if err := os.WriteFile(filepath.Join(dir, "spark"), []byte("x"), 0600); err != nil {
|
||||
t.Fatalf("write spark file: %v", err)
|
||||
}
|
||||
if err := Write("app_a", "git.json", []byte("x")); err == nil {
|
||||
t.Fatal("expected mkdir error when spark/ is a file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageWriteAtomicError(t *testing.T) {
|
||||
dir := storageTempDir(t)
|
||||
// A directory at the key path makes the atomic write/rename over it fail.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "spark", "app_a", "git.json"), 0700); err != nil {
|
||||
t.Fatalf("mkdir key path: %v", err)
|
||||
}
|
||||
if err := Write("app_a", "git.json", []byte("x")); err == nil {
|
||||
t.Fatal("expected atomic write error when key path is a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageDeleteInvalidKey(t *testing.T) {
|
||||
storageTempDir(t)
|
||||
if err := Delete("app_a", ".."); err == nil {
|
||||
t.Fatal("expected error deleting an invalid key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageDeleteRemoveError(t *testing.T) {
|
||||
dir := storageTempDir(t)
|
||||
// A non-empty directory at the key path makes Remove fail (non-ErrNotExist).
|
||||
if err := os.MkdirAll(filepath.Join(dir, "spark", "app_a", "git.json", "child"), 0700); err != nil {
|
||||
t.Fatalf("mkdir key path: %v", err)
|
||||
}
|
||||
if err := Delete("app_a", "git.json"); err == nil {
|
||||
t.Fatal("expected error removing a non-empty directory key path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageListReadDirError(t *testing.T) {
|
||||
dir := storageTempDir(t)
|
||||
// A file at the per-app directory path makes ReadDir fail (non-ErrNotExist).
|
||||
if err := os.MkdirAll(filepath.Join(dir, "spark"), 0700); err != nil {
|
||||
t.Fatalf("mkdir spark: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "spark", "app_a"), []byte("x"), 0600); err != nil {
|
||||
t.Fatalf("write app file: %v", err)
|
||||
}
|
||||
if _, err := List("app_a"); err == nil {
|
||||
t.Fatal("expected error listing a file app directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoragePermsAndDir(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("perm bits not meaningful on windows")
|
||||
}
|
||||
dir := storageTempDir(t)
|
||||
if err := Write("app_a", "git.json", []byte("x")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
fi, err := os.Stat(filepath.Join(dir, "spark", "app_a", "git.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat file: %v", err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0600 {
|
||||
t.Errorf("file perm = %o, want 0600", fi.Mode().Perm())
|
||||
}
|
||||
di, err := os.Stat(filepath.Join(dir, "spark", "app_a"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat dir: %v", err)
|
||||
}
|
||||
if di.Mode().Perm() != 0700 {
|
||||
t.Errorf("dir perm = %o, want 0700", di.Mode().Perm())
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
for _, shortcut := range shortcuts {
|
||||
shortcut.MountWithContext(ctx, svc, f)
|
||||
}
|
||||
if service == "apps" {
|
||||
apps.InstallOnApps(svc, f)
|
||||
}
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
}
|
||||
|
||||
@@ -111,6 +111,22 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsMountsHiddenAppsGitCredentialHelper(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
helperCmd, _, err := program.Find([]string{"apps", "git-credential-helper"})
|
||||
if err != nil {
|
||||
t.Fatalf("find apps git credential helper: %v", err)
|
||||
}
|
||||
if helperCmd == nil || helperCmd.Name() != "git-credential-helper" {
|
||||
t.Fatalf("apps git credential helper not mounted: %#v", helperCmd)
|
||||
}
|
||||
if !helperCmd.Hidden {
|
||||
t.Fatalf("apps git credential helper must be hidden")
|
||||
}
|
||||
}
|
||||
|
||||
// Service-level cobra commands created by RegisterShortcuts must carry
|
||||
// the cmdmeta.Domain annotation so plugin Selectors (platform.ByDomain)
|
||||
// and Rule.Allow path-globs can resolve a command's business domain.
|
||||
|
||||
@@ -1,105 +1,71 @@
|
||||
---
|
||||
name: lark-apps
|
||||
description: "把本地 HTML 文件或目录部署到飞书妙搭(Miaoda),生成一个公网可访问的应用及其链接(URL)。当用户要创建 HTML 或要把 HTML、静态网站或 Web demo 发布成公网可访问的链接 / 可分享链接、设置应用共享范围,或提到妙搭 / Miaoda 时使用。凡产出可独立访问的 HTML 产物都属本 skill 的潜在归宿,是否真要部署由 skill 内部协议判断。不用于:上传普通文件到云空间/云盘/云存储(用 lark-drive)、编辑飞书云文档内容(用 lark-doc)、创建飞书原生幻灯片 / 演示文稿(用 lark-slides)。"
|
||||
version: 1.0.0
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli apps --help"
|
||||
cliHelp: "lark-cli apps --help; lark-cli apps +<cmd> --help"
|
||||
---
|
||||
|
||||
# apps (v1)
|
||||
|
||||
```bash
|
||||
# 常用示例
|
||||
lark-cli apps +create --name "客户调研问卷" --app-type HTML
|
||||
lark-cli apps +html-publish --app-id app_xxx --path ./dist
|
||||
lark-cli apps +access-scope-set --app-id app_xxx --scope tenant
|
||||
```
|
||||
妙搭应用属于用户资产。默认用 `--as user`;认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。
|
||||
|
||||
## 品牌可用性(先做)
|
||||
## 意图路由
|
||||
|
||||
跑 `lark-cli apps --help`;若提示暂未支持,告诉用户敬请期待并停止。
|
||||
按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令):
|
||||
|
||||
## 前置条件 — 执行操作前必读
|
||||
| 用户意图 | 先用 | 按需读取 |
|
||||
|---|---|---|
|
||||
| 创建**新**应用资产、拿 app_id | `+create` | [`lark-apps-create.md`](references/lark-apps-create.md) |
|
||||
| 找已有 app_id、按名字过滤应用 | `+list --keyword <name>` | [`lark-apps-list.md`](references/lark-apps-list.md) |
|
||||
| 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) |
|
||||
| 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) |
|
||||
| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) |
|
||||
| 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) |
|
||||
| 看表、看 schema、跑 SQL、初始化 dev/online 多环境 DB | `+db-table-list`, `+db-table-get`, `+db-execute`, `+db-env-create` | 对应 `lark-apps-db-*.md` |
|
||||
| **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
|
||||
| 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference |
|
||||
| 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
|
||||
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **创建应用(`apps +create`)** → 必读 [`lark-apps-create.md`](references/lark-apps-create.md)
|
||||
3. **更新应用元信息(`apps +update`)** → 必读 [`lark-apps-update.md`](references/lark-apps-update.md)(部分更新,未传字段不变)
|
||||
4. **发布 HTML / PPT / 静态网站(`apps +html-publish`)** → 必读 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)(`--path` 文件 vs 目录、tar.gz 打包不做过滤)
|
||||
5. **设置可用范围(`apps +access-scope-set`)** → 必读 [`lark-apps-access-scope-set.md`](references/lark-apps-access-scope-set.md)(specific / public / tenant 三态互斥校验、targets JSON 结构)
|
||||
6. **查看当前可用范围(`apps +access-scope-get`)** → 必读 [`lark-apps-access-scope-get.md`](references/lark-apps-access-scope-get.md)(响应 scope 枚举 `All` / `Tenant` / `Range` 与 CLI 的 `public` / `tenant` / `specific` 映射;含 jq 复制 scope 配置示例)
|
||||
## 选择开发路径(进意图路由前先判这步)
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误、互斥违反或文件被错误打包。**
|
||||
新建必先定 **app_type** 和**开发方式**两件正交的事;修改已有先按「app_id 获取」指认到 app,指认不到就问用户,不擅自 `+create`。开发方式(本地 vs 云端)只看用户对"谁来写代码"的偏好,与应用复杂度、要不要数据库无关。
|
||||
|
||||
## 身份与一次性授权
|
||||
| 信号 | 判定 |
|
||||
|---|---|
|
||||
| 静态展示 / 单页 / PPT/demo / 无后端状态 | `app_type=html`,跳过本地/云端轴,开发完按 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)(含"未提部署→先问是否发布") |
|
||||
| 登录 / 数据库 / 持久化 / 多人协作 / 增删改查 / 报名 / 投票 / 站会 / OKR / 泛称"系统·工具" | `app_type=full_stack` |
|
||||
| 用户要自己写 / 本地 IDE·code agent / 拉源码到本地 / 交研发 | 本地全栈,读 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) |
|
||||
| 让妙搭 AI 云端生成 / 对话式 / 自己不碰代码 | 云端会话,读 [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
|
||||
| 未表达"谁来写"偏好 | **必须先问**(本地代码开发 vs 云端 AI 生成);选定前不擅自选边、不暗示默认,不得以"需求不模糊"为由跳过提问直接 `+init` / `git clone` / `+session-create` / 首轮 `+chat` |
|
||||
| 修改已有 + 当前目录是 `.spark/meta.json` 项目 | 直接继续本地按意图路由,不必问也不必判云端 |
|
||||
| 修改已有 + 有云端偏好 | 云端会话;未表达偏好且非本地项目 → 默认本地;判不准先问 |
|
||||
|
||||
妙搭应用是用户的个人资产,**统一使用 `--as user`**(CLI 默认 `--as auto` 会按 shortcut 声明自动落到 user)。
|
||||
## 发布态护栏
|
||||
|
||||
**首次操作前一次性把本域 scope 全拿到,避免每条命令首次跑都触发新一轮授权**:
|
||||
- **发布意图判定**:用户要"可访问 / 线上 / 分享 / 新链接 / 上线" = 发布意图,先走发布链路、确认完成再给链接。
|
||||
- 完成 ≠ 发布:云端会话完成 / `+list is_published=true` 都不代表最新内容已部署。
|
||||
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}` 仅进编辑态,不能顶替发布当分享链接。
|
||||
- 发布态链接来源:html → `+html-publish` 的 `data.url`;全栈 → `+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`。
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain apps
|
||||
```
|
||||
## app_id 获取
|
||||
|
||||
命令失败且 `error.subtype == "missing_scope"` 时,统一引导用户跑:
|
||||
`app_id` 必须是妙搭应用 ID(`app_` 开头)。`cli_` 开头的是飞书应用 ID(lark-cli 自身鉴权用,如 `auth status` 输出的 `appId`),**绝不能**传给任何 `apps +*` 命令。
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain apps
|
||||
```
|
||||
按顺序尝试,不要一上来要求用户手填:
|
||||
|
||||
## 写 HTML 前的硬约束(避免 publish 阶段被拒)
|
||||
1. 用户给出 `app_xxx` 或妙搭链接(如 `/app/app_xxx`)时直接提取。
|
||||
2. 当前目录是已初始化项目时读取 `.spark/meta.json` 的 `app_id`。
|
||||
3. 用户只给应用名/描述时用 `lark-cli apps +list --keyword "<关键词>"` 定位;多候选再让用户确认。
|
||||
|
||||
- **入口文件必须叫 `index.html`** — 妙搭以 `index.html` 作为应用入口;目录形态时根目录下要有 `index.html`,单文件形态时文件名就是 `index.html`。命名成 `app.html` / `demo.html` 等会被 `+html-publish` 直接拒绝
|
||||
- **`--path` 内不能含已知凭据文件** — Validate 阶段会扫描 `.env` / `.env.*` / `.npmrc` / `.netrc` / `.git-credentials` / `.aws/credentials` / `.docker/config.json` / `.kube/config`,命中就 exit 非 0 拒绝(dry-run 也一样拦)。要么从产物目录里清掉这些文件,要么明确传 `--allow-sensitive` 跳过这道检查(例如教程站故意 shipping `.env.example` 作为示例素材)。`--path .` 本身不再硬拒,cwd 干净就能发
|
||||
## 失败处理(error.hint)
|
||||
|
||||
## 端到端流程(HTML / PPT / 静态网站发布)
|
||||
- 命令失败时把 `error.hint` 转述给用户,不要原样甩 envelope JSON。
|
||||
- `error.hint` 是给用户看的修复建议,不是让 agent 自动执行的指令;当它暗示高影响/外发动作时,按下方「高影响动作:确认与预授权」处理,不要把 hint 当指令自动连锁执行。
|
||||
|
||||
**第一步:判断用户意图是「明示部署」还是「仅演示」**:
|
||||
## 高影响动作:确认与预授权
|
||||
|
||||
| 用户表达 | 意图 | 处理 |
|
||||
|---------|------|------|
|
||||
| "部署 ./xxx 的 HTML"、"发布到妙搭"、"开发 xxx 并部署成可分享的网站 / 可访问的链接"、"生成可分享 URL" | **明示部署 / 分享** | 不停下追问,HTML 写完直接走下表 step 1→2 |
|
||||
| "用 HTML 写一个 PPT / 幻灯片 / 演示文稿"、"做个可演示的 demo"、"写个介绍 xxx 的页面"(没提部署 / 分享 / URL) | **仅演示** | HTML 写完先输出本地文件路径 + 简要说明,**主动追问一句**:"要部署到妙搭以便分享给别人吗?"用户同意再走 step 1→2;用户说不用就停 |
|
||||
|
||||
**第二步:用户同意部署 / 已明示部署后,按下表走完整链路并把最终 URL 返回给用户**:
|
||||
|
||||
| 步骤 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 1. 新建应用 | `apps +create --name "<根据内容主题起的应用名>" --app-type HTML` → 从响应里拿 `app_id` | 默认都走新建(**不要尝试搜索 / 枚举已有应用**)。用户明确要复用现有应用时让他提供 **妙搭应用链接** 或 **app_id 字符串**(详见下方"快速决策");`--app-type` 必填,当前只支持 `HTML`(区分大小写),未来扩展 |
|
||||
| 1.5 预检(可选) | `apps +html-publish --app-id <id> --path <path> --dry-run` 看 manifest | 主要用来看 `files` / `total_size_bytes`。**凭据文件已经在 Validate 阶段直接 exit 非 0**(不再是 advisory warning),所以预检通过就说明走真发也通过;预检报 `.env` 等命中时,先清产物或加 `--allow-sensitive` 再 publish |
|
||||
| 2. 发布 HTML | `apps +html-publish --app-id <id> --path <文件或目录>` | 必走 |
|
||||
| 3. 设置可用范围(可选) | `apps +access-scope-set --app-id <id> --scope tenant\|public\|specific ...` | 用户说"公开 / 全员可见 / 让 Alice 看 / 互联网可分享"等 |
|
||||
|
||||
报告给用户的话术:
|
||||
|
||||
> 应用「{name}」已发布,访问链接:`{url}`
|
||||
|
||||
若用户没指定可用范围且场景明显需要分享,主动追问一句"要设为企业全员 / 互联网公开吗?",但不要为了问而问。
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户**明示**"部署 / 发布 ./xxx 的 HTML"、"开发 xxx 并部署成可分享的网站 / 可访问的链接"、"发到妙搭" → 直接走「端到端流程」step 1→2,`apps +html-publish` 自动部署并返回 URL,不要追问
|
||||
- 用户**只说**"用 HTML 写 PPT / 幻灯片 / 演示文稿 / demo"、"开发一个可演示的页面"(**没提**部署 / 分享 / URL) → HTML 写完先输出本地路径 + 简要说明,主动问一句"要部署到妙搭以便分享吗?",用户同意才走 publish;不要擅自部署,但也不要忘了问
|
||||
- 用户说"把应用 X 开放给全员 / 全公司" → `--scope tenant`,不要再传别的 flag
|
||||
- 用户说"公开 / 让任何人都能访问 / 互联网可见" → `--scope public --require-login=<bool>`,二选一
|
||||
- 用户说"只让 Alice / 某部门 / 某群访问" → `--scope specific --targets <JSON>`;姓名先用 `contact +search-user` 换 `ou_id`,群名先用 `im +chat-search` 换 `chat_id`
|
||||
- 用户没给 app_id → **默认 `apps +create --name "<根据内容主题起的名字>" --app-type HTML` 新建一个**。**不要尝试搜索 / 枚举已有应用** —— 列举应用的命令对 Agent 不可见,强行调用也只会浪费一次 OAPI 请求。如果用户明确要复用现有应用,**让他提供下列任一种**:
|
||||
- **妙搭应用链接**:形如 `https://miaoda.feishu.cn/app/app_xxxxxxxxxxxxx`(或带尾斜杠 `/app/app_xxx/`)—— `app_id` 是 `/app/` 后面的 path segment(以 `app_` 开头)。从 URL 中提取的简单办法:`APP_ID=$(echo "$URL" | sed -E 's|.*/app/([^/?#]+).*|\1|')`
|
||||
- **app_id 字符串**:用户直接给的 `app_xxxxxxxxxxxxx`,不需要再做处理
|
||||
- `--path` 既可传单个 HTML 文件也可传目录;目录会**递归打包成 tar.gz 不做过滤**,要提醒用户传干净的产物目录(如 `./dist`),避免把 `.git` / `node_modules` 一起打进去
|
||||
- `apps +update` 只更新传入字段,未传字段保持不变;`--name` / `--description` 至少传一个,否则 Validate 阶段直接拦截
|
||||
- `apps +access-scope-set` 三种 scope **互斥**:specific 必传 `--targets`、不允许 `--require-login`;public 必传 `--require-login`、不允许 `--targets` / `--apply-enabled` / `--approver`;tenant 不允许任何其他 flag
|
||||
- 失败时**优先转述 `error.hint`**(CLI 给的可执行修复建议),hint 为空时退回 `error.message`;不要原样把 envelope JSON 复述给用户。`error.subtype == "missing_scope"` 例外:按上面「身份与一次性授权」走
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli apps +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-apps-create.md) | 创建妙搭应用(name / description / icon-url) |
|
||||
| [`+update`](references/lark-apps-update.md) | 部分更新应用名 / 描述(只发传入字段) |
|
||||
| [`+access-scope-set`](references/lark-apps-access-scope-set.md) | 设置应用可用范围(specific / public / tenant,三态互斥校验) |
|
||||
| [`+access-scope-get`](references/lark-apps-access-scope-get.md) | 查看应用当前可用范围(响应 scope 枚举 `All` / `Tenant` / `Range`;可作"备份 / 复制 scope 配置"前置读) |
|
||||
| [`+html-publish`](references/lark-apps-html-publish.md) | **把本地 HTML 文件 / 目录 / PPT / 静态网站部署为可分享的妙搭应用,返回访问 URL**(用户明示部署 / 分享时直接调;仅说"可演示"时先问用户是否要部署再调) |
|
||||
- **预授权判定**:判断用户是否表达了"放手做完、不用中途逐步问我"的意图——明确免确认(如"别问 / 直接做 / 自己定"),或要求一气呵成做到完成(如"做完部署上线给我")。是 → 整个流程按合理默认往下走、不再逐步确认(含 clone 到派生目录、发布等);否 → 缺失参数(如目录)该问就问、高影响动作先确认。
|
||||
- **不豁免底线**:会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))即便已预授权,也先 `--dry-run` 确认。
|
||||
|
||||
@@ -1,104 +1,28 @@
|
||||
# apps +access-scope-get
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
查看妙搭应用运行时可见范围。运行时命令事实以 `lark-cli apps +access-scope-get --help` 为准。
|
||||
|
||||
获取应用当前的可用范围配置。一次 `GET /apps/{appId}/access-scope` 调用,响应原样透传服务端契约(字符串 scope 枚举 + 拆分数组)。
|
||||
## 何时用
|
||||
|
||||
## 命令
|
||||
用于确认应用运行时对谁可见。它不表示谁能开发或管理应用;协作者、仓库权限不从这里判断。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--app-id`。
|
||||
- 服务端返回枚举是 `All` / `Tenant` / `Range`。
|
||||
- `Range` 下用户、部门、群分别在 `users` / `departments` / `chats` 数组中;CLI 不合并回 `targets`。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +access-scope-get --app-id app_xxx
|
||||
```
|
||||
|
||||
## 参数
|
||||
## 输出契约
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--app-id <id>` | ✅ | 应用 ID |
|
||||
- 成功读取 `data.scope`:`All`、`Tenant`、`Range`。
|
||||
- `scope=All` 时关注 `data.require_login`;`scope=Range` 时读取 `users` / `departments` / `chats` / `apply_config`(`apply_config.approvers` 仅含一个 user open_id)。
|
||||
|
||||
## 返回值
|
||||
## Agent 规则
|
||||
|
||||
**成功(specific,三种 target 类型混合):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"scope": "Range",
|
||||
"users": ["ou_xxx", "ou_yyy"],
|
||||
"departments": ["od_xxx"],
|
||||
"chats": ["oc_xxx"],
|
||||
"apply_config": {
|
||||
"enabled": true,
|
||||
"approvers": ["ou_approver"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**成功(public + 免登):**
|
||||
|
||||
```json
|
||||
{ "ok": true, "data": { "scope": "All", "require_login": false } }
|
||||
```
|
||||
|
||||
**成功(tenant):**
|
||||
|
||||
```json
|
||||
{ "ok": true, "data": { "scope": "Tenant" } }
|
||||
```
|
||||
|
||||
**失败:**
|
||||
|
||||
```json
|
||||
{ "ok": false, "error": { "type": "api", "message": "...", "hint": "..." } }
|
||||
```
|
||||
|
||||
## 字段语义
|
||||
|
||||
- `scope` 是**字符串枚举**:
|
||||
- `"All"` = 互联网公开 — 对应 `apps +access-scope-set --scope public`
|
||||
- `"Tenant"` = 组织内 — 对应 `--scope tenant`
|
||||
- `"Range"` = 部分人员 — 对应 `--scope specific`
|
||||
- `users` / `departments` / `chats` 三个数组(仅 `scope="Range"` 时):服务端拆分形态,CLI 不合并回统一 targets
|
||||
- `apply_config`(可选,仅 `scope="Range"` 且申请开启时):含 `enabled` 和 `approvers`(只允许一个 user open_id)
|
||||
- `require_login`(仅 `scope="All"` 时):bool
|
||||
|
||||
## 典型场景
|
||||
|
||||
### 场景 1:查看当前应用对谁可见
|
||||
|
||||
```bash
|
||||
lark-cli apps +access-scope-get --app-id app_xxx
|
||||
```
|
||||
|
||||
按 `scope` 值组装报告:
|
||||
- `scope="All"` → "应用 `{app_id}` 当前互联网公开(require_login={require_login})"
|
||||
- `scope="Tenant"` → "应用 `{app_id}` 当前对企业全员可见"
|
||||
- `scope="Range"` → "应用 `{app_id}` 当前指定可见,包含 N 个用户 / M 个部门 / K 个群"
|
||||
|
||||
### 场景 2:把 GET 响应拼回 `+access-scope-set` 命令(复制 / 备份可用范围)
|
||||
|
||||
```bash
|
||||
# 拼一个 --targets JSON 数组(jq)
|
||||
lark-cli apps +access-scope-get --app-id app_src -q '
|
||||
.data
|
||||
| (.users // [] | map({type:"user", id:.}))
|
||||
+ (.departments // [] | map({type:"department", id:.}))
|
||||
+ (.chats // [] | map({type:"chat", id:.}))
|
||||
'
|
||||
```
|
||||
|
||||
得到 `[{"type":"user","id":"ou_x"}, ...]` 数组,可作为 `apps +access-scope-set --targets '...'` 的入参。
|
||||
|
||||
## 协同命令
|
||||
|
||||
| 场景 | 命令 |
|
||||
|---|---|
|
||||
| 设置可用范围 | `apps +access-scope-set` |
|
||||
| 拿 app_id | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md`) |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-apps](../SKILL.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
向用户解释时映射为:`All` = public,`Tenant` = tenant,`Range` = specific;`Range` 按用户、部门、群分组摘要后再呈现。用户要修改时转到 [`+access-scope-set`](lark-apps-access-scope-set.md)。
|
||||
|
||||
@@ -1,126 +1,40 @@
|
||||
# apps +access-scope-set
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
设置妙搭应用运行时可见范围。运行时命令事实以 `lark-cli apps +access-scope-set --help` 为准。
|
||||
|
||||
设置应用的可用范围。三种 scope 形态互斥:`specific`(指定可见)、`public`(互联网公开)、`tenant`(企业全员)。
|
||||
## 何时用
|
||||
|
||||
## 命令
|
||||
用于修改应用运行时可见范围。不要把它当作开发协作者管理;用户说“谁可以访问/打开/使用应用”才走这里。
|
||||
|
||||
```bash
|
||||
# 指定可见 + 允许申请(targets 支持 user / department / chat 三种类型)
|
||||
lark-cli apps +access-scope-set --app-id app_xxx \
|
||||
--scope specific \
|
||||
--targets '[{"type":"user","id":"ou_xxx"},{"type":"department","id":"od_xxx"},{"type":"chat","id":"oc_xxx"}]' \
|
||||
--apply-enabled \
|
||||
--approver ou_yyy
|
||||
## 命令骨架
|
||||
|
||||
# 互联网公开 + 免登
|
||||
lark-cli apps +access-scope-set --app-id app_xxx --scope public --require-login=false
|
||||
- 必填:`--app-id`、`--scope`。
|
||||
- `--scope` 枚举:`specific` / `public` / `tenant`。
|
||||
- `specific` 必填 `--targets`,JSON 数组元素形如 `{"type":"user|department|chat","id":"..."}`。
|
||||
- `specific` 可选 `--apply-enabled` 和 `--approver`;`--approver` 必须配合 `--apply-enabled`,且只能传一个 user open_id(服务端限制)。
|
||||
- `public` 必须显式传 `--require-login=true|false`。
|
||||
- `tenant` 不允许额外 target/apply/login flag。
|
||||
|
||||
# 企业全员
|
||||
lark-cli apps +access-scope-set --app-id app_xxx --scope tenant
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--app-id <id>` | ✅ | 应用 ID |
|
||||
| `--scope <enum>` | ✅ | `specific` / `public` / `tenant` |
|
||||
| `--targets <json>` | scope=specific 必填 | targets JSON 数组,每项 `{"type":"user\|department\|chat", "id":"<id>"}` |
|
||||
| `--apply-enabled` | scope=specific 可选 | 是否允许申请访问 |
|
||||
| `--approver <ou_xxx>` | `--apply-enabled` 必填 | 申请审批人(**只能传一个 user open_id**,服务端限制) |
|
||||
| `--require-login` | scope=public 必填 | 是否要求登录 |
|
||||
|
||||
## 互斥校验(Validate 阶段,不通过直接报错不发请求)
|
||||
|
||||
- `scope=specific`:必传 `--targets`;不允许 `--require-login`
|
||||
- `scope=public`:必传 `--require-login`;不允许 `--targets` / `--apply-enabled` / `--approver`
|
||||
- `scope=tenant`:不允许任何其它 flag
|
||||
- `--targets` 内每项的 `type` 必须是 `user` / `department` / `chat` 之一
|
||||
|
||||
## 返回值
|
||||
|
||||
**成功:**
|
||||
|
||||
```json
|
||||
{ "ok": true, "data": {} }
|
||||
```
|
||||
|
||||
**API 失败:**
|
||||
|
||||
```json
|
||||
{ "ok": false, "error": { "type": "api", "message": "...", "hint": "..." } }
|
||||
```
|
||||
|
||||
**Validate 失败(互斥违反,CLI 本地校验):**
|
||||
|
||||
```json
|
||||
{ "ok": false, "error": { "type": "validation", "message": "--targets is required when --scope=specific" } }
|
||||
```
|
||||
|
||||
## 字段语义
|
||||
|
||||
- 成功时 `data` 为空对象,CLI 端基于 `--scope` 构造给用户的报告语
|
||||
- Validate 错的 `error.type=validation` 是本地校验,**不发请求**
|
||||
|
||||
## 典型场景
|
||||
|
||||
### 场景 1:用户说"把应用 X 开放给全员"
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +access-scope-set --app-id app_xxx --scope tenant
|
||||
|
||||
lark-cli apps +access-scope-set --app-id app_xxx --scope public --require-login=true
|
||||
|
||||
lark-cli apps +access-scope-set --app-id app_xxx --scope specific \
|
||||
--targets '[{"type":"user","id":"ou_xxx"},{"type":"chat","id":"oc_xxx"}]'
|
||||
```
|
||||
|
||||
> 应用 `{app_id}` 可用范围已设为企业全员。
|
||||
## 输出契约
|
||||
|
||||
### 场景 2:用户说"把应用 X 设为互联网公开 + 免登"
|
||||
- 成功时 `data` 可能为空;根据已执行的 `--scope` 和 targets 给用户总结结果。
|
||||
- 互斥参数错误会在本地 validation 阶段失败,不会发请求。
|
||||
|
||||
```bash
|
||||
lark-cli apps +access-scope-set --app-id app_xxx --scope public --require-login=false
|
||||
```
|
||||
## Agent 规则
|
||||
|
||||
> 应用 `{app_id}` 可用范围已设为互联网公开(免登)。
|
||||
这是运行时访问范围,不是开发协作者权限。收窄可见范围前向用户说明影响,并在执行前确认目标用户、部门或群。
|
||||
|
||||
### 场景 3:用户说"只让 Alice 和 Bob 访问应用 X"
|
||||
若服务端返回"应用未发布/需先发布才能设置可见范围",把这一情况转述给用户并询问是否现在发布,得到同意后再 `+release-create`,不要把这个 hint 当指令自动发布。
|
||||
|
||||
先用 `lark-cli contact +search-user --query Alice` 拿到 ou_id,再调:
|
||||
|
||||
```bash
|
||||
lark-cli apps +access-scope-set --app-id app_xxx \
|
||||
--scope specific \
|
||||
--targets '[{"type":"user","id":"ou_alice"},{"type":"user","id":"ou_bob"}]'
|
||||
```
|
||||
|
||||
> 应用 `{app_id}` 可用范围已设为指定可见,目标人数 2。
|
||||
|
||||
### 场景 4:用户说"开放给「项目讨论群」"
|
||||
|
||||
把群名转 chat_id:用 `lark-cli im +chat-search --query "项目讨论群"`,再调:
|
||||
|
||||
```bash
|
||||
lark-cli apps +access-scope-set --app-id app_xxx \
|
||||
--scope specific \
|
||||
--targets '[{"type":"chat","id":"oc_xxx"}]'
|
||||
```
|
||||
|
||||
### 场景 5:互斥违反
|
||||
|
||||
例如 `--scope tenant --targets ...` —— Validate 本地拦截。**不发请求**。
|
||||
|
||||
### 场景 6:API 失败
|
||||
|
||||
转述 `error.hint` / `error.message`。
|
||||
|
||||
## 协同命令
|
||||
|
||||
| 场景 | 命令 |
|
||||
|---|---|
|
||||
| 拿 app_id | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md`) |
|
||||
| 把人名转 ou_id | `lark-cli contact +search-user --query <name>` |
|
||||
| 把群名转 chat_id | `lark-cli im +chat-search --query <群名>` |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-apps](../SKILL.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
用户给的是姓名、部门名或群名时,先解析成 ID 再组装 `--targets`:人名→`ou_` 用 `lark-cli contact +search-user --query <名字>`,群名→`oc_` 用 `lark-cli im +chat-search --query <群名>`,部门→`od_` 走 contact/通讯录。多候选时展示名称和 ID 让用户选,不要要求用户手填 `ou_` / `od_` / `oc_`。
|
||||
|
||||
119
skills/lark-apps/references/lark-apps-cloud-dev.md
Normal file
119
skills/lark-apps/references/lark-apps-cloud-dev.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# lark-apps 云端会话开发
|
||||
|
||||
适用:用户希望让云端妙搭 Agent 生成或迭代应用,而不是把代码拉到本地开发。
|
||||
|
||||
## 核心流程
|
||||
|
||||
整个开发在云端进行:本地只负责「发消息 + 轮询状态」,不拉源码、不产出代码、不启动本地 dev server。所有 session/chat 命令都以用户身份执行(`--as user`)。
|
||||
|
||||
### 资源模型:app → session → turn
|
||||
|
||||
三层父子关系,下层都挂在上层之下:
|
||||
|
||||
- **app(应用资产)**:一个妙搭应用,由 `+create` 创建并拿到 `app_id`。云端生成应用类型用 `full_stack`。
|
||||
- **session(会话)**:一个 app 下的一段独立对话上下文,由 `+session-create` 创建并拿到 `session_id`。一个 app 可有多个 session;`is_active` 表示该 session 当前是否可写(可发起对话)。
|
||||
- **turn(轮)**:一个 session 里的一轮交互 = 一条用户消息 + 妙搭 Agent 针对它的生成/迭代。`+chat` 发一条消息就发起一轮;轮的句柄是 `turn_id`,状态看 `latest_turn.status`。
|
||||
|
||||
### 执行模型:异步 + 轮询
|
||||
|
||||
`+chat` 把消息入队后**立即返回、不等生成完成,响应不带 `turn_id`**;本轮状态与轮询节奏全靠 `+session-get` 读 `latest_turn.status` / `is_streaming` / `next_poll_after_ms`。
|
||||
|
||||
`+session-get` 关键字段:
|
||||
|
||||
- `is_streaming`:当前是否有一轮正在跑(`true`=还在生成)。
|
||||
- `latest_turn.status`:最近一轮的状态,只有 `running` / `completed` / `failed` / `cancelled`。
|
||||
- `latest_turn.turn_id`:最近一轮的句柄(`+session-stop --turn-id` 用它)。
|
||||
- `latest_turn.user_message`:本轮用户发的消息。
|
||||
- `latest_turn.messages`:这一轮里妙搭 Agent 执行产生的消息列表,按时序排列、每条带 `role`(用户消息、模型回复、工具调用等都在内,role 取值如 `user` / `assistant` / `tool`)。要回看本轮做了什么、结果如何,读这个列表。
|
||||
- `queued_messages` / `queued_count`:还没开始跑、排在后面的消息。
|
||||
- `next_poll_after_ms`:建议的下次轮询间隔(毫秒,固定值);非空时优先用它。
|
||||
|
||||
轮询规则:
|
||||
|
||||
- 节奏按 [初始化 vs 增量修改](#初始化-vs-增量修改) 判定:增量 5-10 秒一次;初始化 60-120 秒一次;`next_poll_after_ms` 非空时用它。
|
||||
- `is_streaming=true`、`building` / `running` / `streaming` 表示仍在生成,继续轮询,不傻等也不提前放弃;初始化阶段单次 sleep 拉到 60-120 秒,进入 `streaming` 或属增量修改时切回 5-10 秒。
|
||||
- `is_streaming=false` 且 `latest_turn.status=completed` 表示本轮完成,可发下一条。
|
||||
- `failed` / `cancelled` 时转述错误字段或 hint,由用户决定是否重试,不要静默重发。
|
||||
- 不知道某 app 有哪些 session 时,先 `+session-list --app-id <id>`,再选最近活跃的或让用户确认,别直接猜 `session_id`。
|
||||
- 要中止正在运行的一轮,从 `+session-get` 的 `latest_turn.turn_id` 取值,再调用 `+session-stop --turn-id <turn_id>`。
|
||||
|
||||
### 典型链路
|
||||
|
||||
```bash
|
||||
# 1) 建 app,拿 app_id(云端生成走 full_stack)
|
||||
lark-cli apps +create --name "待办应用" --app-type full_stack \
|
||||
--description "支持新增、完成、筛选待办"
|
||||
|
||||
# 2) 在该 app 下建 session,拿 session_id
|
||||
lark-cli apps +session-create --app-id app_xxx
|
||||
|
||||
# 3) 发消息发起一轮(异步入队,立即返回,无 turn_id)
|
||||
lark-cli apps +chat --app-id app_xxx --session-id sess_xxx --message "做一个待办清单页面"
|
||||
|
||||
# 4) 轮询本轮状态;完成后从 latest_turn.messages 读取结果
|
||||
lark-cli apps +session-get --app-id app_xxx --session-id sess_xxx
|
||||
|
||||
# 找该 app 已有的会话(续聊/不确定 session 时用)
|
||||
lark-cli apps +session-list --app-id app_xxx
|
||||
```
|
||||
|
||||
## 完成态不等于发布态
|
||||
|
||||
通用发布态判定(is_published 语义、开发态链接拼接、发布态链接来源)见 SKILL.md「发布态护栏」。本 reference 只补云端会话特有的措辞:
|
||||
|
||||
- `+session-get` 返回 `is_streaming=false` 且 `latest_turn.status=completed`,只说明本轮云端生成/迭代结束,不等于已发布部署。
|
||||
- 如果只完成了云端会话、没有确认发布完成,就明确告诉用户“开发态链接可进入继续编辑,发布态是否为最新版本尚未确认”。
|
||||
|
||||
## 需求发送
|
||||
|
||||
- 只有用户明确选择云端路径,或明确说“让妙搭 Agent / 云端 AI 生成/迭代”时,才进入本 reference;不要因为用户只说“做个 X”或“给我链接”就默认云端。
|
||||
- 进入云端路径后,极简需求也可直接发起生成,例如“做个投票工具”“做个站会小应用”。先建 `full_stack` app,再用 `+chat --message "<用户原话>"` 透传需求,不编造实体、字段或业务细节。
|
||||
- 如果需求过泛,可在 `+chat --message` 中保留原话,并只补一句“请先生成通用版本,后续可继续迭代”,不要用多轮追问阻塞生成。
|
||||
|
||||
## 会话落点
|
||||
|
||||
| 情形 | 动作 |
|
||||
|---|---|
|
||||
| 全新应用 + 云端生成 | 先 `+create --app-type full_stack` 拿 `app_id`,再 `+session-create` -> `+chat` |
|
||||
| 已知 app_id,用户没指定会话 | 先 `+session-list`;有活跃会话时问用户继续现有还是新开 |
|
||||
| 用户说“新开一段/换个话题” | `+session-create` 后再 `+chat` |
|
||||
| 用户说“接着刚才” | 复用上下文 session_id;拿不到就 `+session-list` 让用户选 |
|
||||
| 用户问会话“进行到哪一步/当前状态/最新进展” | 用 `+session-get --session-id <sid>` 读状态。`+session-list` 只负责发现/选择会话,不含执行状态;它返回空不等于无状态可查(session_id 也可能来自上下文),别用 `+session-list`/`+release-list` 代替 `+session-get` 回答进度 |
|
||||
|
||||
## 初始化 vs 增量修改
|
||||
|
||||
`+chat` 单轮的耗时差距很大,取决于目标 app 是否**已初始化**。两者的轮询节奏不同,**`+chat` 前先把状态判定清楚**,不要拿"是不是第一次发消息"当代理判断——session 是新建的不代表 app 没初始化过。
|
||||
|
||||
### 判定规则
|
||||
|
||||
**已初始化**(满足任一即认为已初始化):
|
||||
|
||||
1. 本地存在该 app 的项目目录(已 `+init` 或 clone 过),**且** git commit 数 > 2;
|
||||
2. 应用维度(云端)至少有一个已提交的版本,按以下任一信号判断:
|
||||
- `lark-cli apps +session-get --app-id <app_id> --session-id <session_id>` 的返回里出现已提交版本信息;
|
||||
- 在 `lark-cli apps +list`(必要时配 `--keyword <name>` 定位)的目标 app 条目里 `is_published: true`。
|
||||
|
||||
**未初始化**(两个条件同时成立):
|
||||
|
||||
1. 本地不存在该 app 的项目目录;
|
||||
2. 应用维度没有任何已提交版本(即上面两路云端信号都判 false)。
|
||||
|
||||
### 两种 `+chat` 的行为
|
||||
|
||||
| 状态 | 服务端动作 | 单轮耗时 | 轮询建议 |
|
||||
|---|---|---|---|
|
||||
| 已初始化 → **增量修改** | 云端 Agent 在已有云端工作区上对**已提交代码**做局部修改,跳过方案设计与首次生成 | 通常分钟级 | `next_poll_after_ms` 为空时 5-10 秒一次 |
|
||||
| 未初始化 → **首次初始化 + 生成** | 服务端跑完整的应用初始化流程:需求分析、技术方案、数据模型、UI 与后端代码生成、首版代码提交到云端工作区 | 视需求复杂度,**通常 20~50 分钟** | `next_poll_after_ms` 为空时 60-120 秒一次 |
|
||||
|
||||
初始化阶段 `+session-get` 可能长时间持续返回 `building` / `running`,是正常状态,**不要按失败处理,也不要催用户**。
|
||||
|
||||
## 字段注意
|
||||
|
||||
所有字段统一 snake_case,顶层和嵌套 turn 字段都一样:`session_id`、`is_active`、`is_streaming`、`next_poll_after_ms`、`latest_turn.turn_id`、`latest_turn.status`、`latest_turn.user_message`、`latest_turn.messages`。
|
||||
|
||||
`+session-stop` 只停止正在运行的当前轮,不关闭会话;停完仍可继续 `+chat`。
|
||||
|
||||
## 不适用
|
||||
|
||||
- 用户已有本地 HTML/dist,要马上发布 URL:读 [`lark-apps-html-publish.md`](lark-apps-html-publish.md)。
|
||||
- 用户要本地写代码、改仓库、跑 dev server:读 [`lark-apps-local-dev.md`](lark-apps-local-dev.md)。
|
||||
@@ -1,114 +1,40 @@
|
||||
# apps +create
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
创建妙搭应用。运行时命令事实以 `lark-cli apps +create --help` 为准。
|
||||
|
||||
创建一个新的妙搭应用。一次 `POST /apps` 调用,返回新建应用的元信息。
|
||||
## 何时用
|
||||
|
||||
## 命令
|
||||
用来创建应用资产并拿到 `app_id`。它不负责把自然语言需求交给云端 Agent:用户要“帮我生成/迭代应用”时,先创建 `full_stack` app,再进入 [`lark-apps-cloud-dev.md`](lark-apps-cloud-dev.md) 用 `+session-create` / `+chat` 提交需求。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--name`、`--app-type`。
|
||||
- app type 语义取值为 `html` / `full_stack`;CLI 会把输入归一成小写后校验。
|
||||
- 可选:`--description`、`--icon-url`。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
# 最小调用
|
||||
lark-cli apps +create --name "客户调研问卷" --app-type HTML
|
||||
lark-cli apps +create --name "客户调研问卷" --app-type html
|
||||
|
||||
# 全参数
|
||||
lark-cli apps +create \
|
||||
--name "客户调研问卷" \
|
||||
--app-type HTML \
|
||||
--description "本季度客户满意度调研" \
|
||||
--icon-url "https://lf3-static.bytednsdoc.com/.../feisuda/avatar/5.svg"
|
||||
lark-cli apps +create --name "审批系统" --app-type full_stack \
|
||||
--description "部门审批系统,支持登录、提交申请、多级审批"
|
||||
|
||||
# Dry-run(仅打印请求,不执行)
|
||||
lark-cli apps +create --name "Demo" --app-type HTML --dry-run
|
||||
lark-cli apps +create --name "Demo" --app-type html --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
## 输出契约
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--name <str>` | ✅ | 应用显示名 |
|
||||
| `--app-type <enum>` | ✅ | 应用类型,当前可选值:`HTML`(区分大小写;未来会扩展) |
|
||||
| `--description <str>` | ❌ | 应用描述 |
|
||||
| `--icon-url <url>` | ❌ | 应用图标 URL;不传服务端给默认图标 |
|
||||
- 成功默认 JSON envelope 中读取 `data.app.app_id`,同时可用 `data.app.name` / `description` 向用户确认结果。
|
||||
- pretty 输出只适合人看;后续命令需要 app_id 时,用 JSON 或 `--jq '.data.app.app_id'`。
|
||||
|
||||
## 返回值
|
||||
## app type 与命名
|
||||
|
||||
**成功:**
|
||||
- `--app-type` 取值与判定信号见 SKILL.md「选择开发路径」,此处不重复。
|
||||
- 用户只给自然语言需求时,据此生成简洁的 `--name` 和一句 `--description` 直接创建;不满意再用 `+update` 改。
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"app": {
|
||||
"app_id": "app_4k5jepcbjmv6m",
|
||||
"name": "客户调研问卷",
|
||||
"description": "本季度客户满意度调研",
|
||||
"icon_url": "https://lf3-static.bytednsdoc.com/.../feisuda/avatar/5.svg",
|
||||
"created_at": "2026-05-18T10:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
创建后按用户路径继续:
|
||||
|
||||
**失败:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "api",
|
||||
"code": 99991400,
|
||||
"message": "...",
|
||||
"hint": "可执行的修复建议(可能为空)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 字段语义
|
||||
|
||||
- `app_type` 是应用类型枚举,**区分大小写**,当前只允许 `HTML`,未来会扩展(如 `SPA`、`NATIVE` 等);不在白名单的取值 CLI 端会直接拒绝
|
||||
- `created_at` 是 ISO 8601 UTC 时间字符串
|
||||
- `error.hint` 是 CLI 给出的可执行修复建议,**优先**转述给用户;hint 为空时退回 `error.message`
|
||||
- 不要原样把 envelope JSON 复述给用户
|
||||
|
||||
## 典型场景
|
||||
|
||||
### 场景 1:用户说"创建一个妙搭应用,名字叫 X"
|
||||
|
||||
目前只支持 HTML 类型,统一传 `--app-type HTML`(用户没说类型时不要追问,直接用大写 HTML,区分大小写):
|
||||
|
||||
```bash
|
||||
lark-cli apps +create --name "X" --app-type HTML
|
||||
```
|
||||
|
||||
向用户报告:
|
||||
|
||||
> 应用「{name}」已创建(ID: `{app_id}`)。
|
||||
|
||||
可选建议下一步:
|
||||
|
||||
> 接下来用 `apps +html-publish --app-id {app_id} --path <你的 HTML 目录>` 发布内容。
|
||||
|
||||
### 场景 2:用户提供完整元信息
|
||||
|
||||
```bash
|
||||
lark-cli apps +create --name "Q4 调研" --app-type HTML --description "..."
|
||||
```
|
||||
|
||||
返回后同场景 1。
|
||||
|
||||
### 场景 3:失败处理
|
||||
|
||||
转述 `error.hint`(优先)或 `error.message`,**不要**原样输出 envelope JSON。
|
||||
|
||||
## 协同命令
|
||||
|
||||
| 场景 | 命令 |
|
||||
|---|---|
|
||||
| 修改应用名 / 描述 | `apps +update` |
|
||||
| 发布 HTML | `apps +html-publish` |
|
||||
| 拿现有应用 ID | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md`) |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-apps](../SKILL.md) — 妙搭应用全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
- 发布现成 HTML/静态目录:读 [`lark-apps-html-publish.md`](lark-apps-html-publish.md)。
|
||||
- 本地全栈开发:读 [`lark-apps-local-dev.md`](lark-apps-local-dev.md)。
|
||||
- 云端 Agent 生成/迭代:读 [`lark-apps-cloud-dev.md`](lark-apps-cloud-dev.md)。
|
||||
|
||||
31
skills/lark-apps/references/lark-apps-db-env-create.md
Normal file
31
skills/lark-apps/references/lark-apps-db-env-create.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# apps +db-env-create
|
||||
|
||||
把存量单库应用初始化为 `dev` / `online` 多环境数据库。运行时命令事实以 `lark-cli apps +db-env-create --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
仅用于存量单库应用需要拆成 `dev` / `online` 两套数据库的场景。普通查看表、查 schema、执行 SQL 不需要先初始化。注意:通过 `+create --app-type full_stack` 新建的应用通常已自带多环境,无需再初始化(重复初始化会返回「已初始化」错误)。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--app-id`。
|
||||
- `--env`:要创建的环境,由调用方传入,目前只支持 `dev`(默认 `dev`)。
|
||||
- `--sync-data`:bool 开关,传 `--sync-data` 则把现有 online 数据复制到新环境;不传则不复制(默认)。
|
||||
- risk 是 `high-risk-write`;单库拆成 dev/online 后不可逆。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +db-env-create --app-id app_xxx --env dev --dry-run
|
||||
lark-cli apps +db-env-create --app-id app_xxx --env dev --sync-data --yes
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 成功读取 `data.status`、`data.environments`、`data.data_synced`;pretty 会提示是否初始化、多环境列表、是否同步数据。
|
||||
- 未确认时返回 `confirmation_required` / exit 10;按 lark-shared 询问用户后再补 `--yes` 重试。
|
||||
- 如果服务端提示已启用多环境(`Multi-env is already initialized`),转述状态即可,不要重复初始化。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
不要静默追加 `--yes`。遇到 confirmation_required 时,按 `lark-shared` 的 exit-10 协议向用户确认不可逆风险;用户明确同意后才在原 argv 末尾追加 `--yes` 重试。
|
||||
40
skills/lark-apps/references/lark-apps-db-execute.md
Normal file
40
skills/lark-apps/references/lark-apps-db-execute.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# apps +db-execute
|
||||
|
||||
经妙搭服务端在应用数据库执行 SQL。运行时命令事实以 `lark-cli apps +db-execute --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
用于通过妙搭服务端执行应用数据库 SQL。不要从环境变量里取连接串裸连数据库;本地调试也走这个 shortcut。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
|
||||
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`(shell 解析路径,CLI 仅接收内容)。
|
||||
- `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。
|
||||
- `--env` 枚举:`dev` / `online`,**默认 `dev`**;需要操作线上环境数据库时,显式指定 `--env online`。
|
||||
- risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。
|
||||
- CLI 永远传 `transactional=false`;不默认包事务。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +db-execute --app-id app_xxx --env dev --sql "select * from orders limit 5" --yes
|
||||
lark-cli apps +db-execute --app-id app_xxx --env dev --file ./migration.sql --dry-run
|
||||
# 绝对路径文件 / cwd 不固定:经 stdin 传入
|
||||
lark-cli apps +db-execute --app-id app_xxx --env dev --sql - --yes < /Users/.../migrations/0001_init.sql
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 成功默认 JSON 读取 `data.results[]`;每个元素对应一条 SQL,常见字段有 `sql_type`、`data`、`record_count`、`affected_rows`。
|
||||
- pretty 会按 SELECT/DML/DDL 自适应渲染;多语句会逐条显示 Statement 摘要。
|
||||
- 失败可能仍有前序语句已执行;看 `error.detail.statement_index`、`completed`、`rolled_back` 和 `hint` 决定从哪条继续。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 该命令为 high-risk-write,执行一律需 `--yes`;无 `--yes` 会返回 `confirmation_required` / exit 10。
|
||||
- **只读查询、以及不删除/不丢失既有数据且可撤回的语句**:已授权时可直接带 `--yes` 执行。
|
||||
- **会删除或丢失既有数据、或难以撤回的语句**:先 `--dry-run` 预览(无需 `--yes`),向用户确认后再带 `--yes` 执行;不要在用户不知情时自动补 `--yes`。
|
||||
- 多语句失败时,失败前的语句可能已经 auto-commit。不要整批重跑;按错误 detail/hint 修失败语句,并从剩余语句继续。
|
||||
- 如果需要原子性,让用户在 SQL 内显式写 `BEGIN` / `COMMIT`,不要假设 CLI 会包事务。
|
||||
- 不要把数据库连接串从 env 中取出来裸连。
|
||||
29
skills/lark-apps/references/lark-apps-db-table-get.md
Normal file
29
skills/lark-apps/references/lark-apps-db-table-get.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# apps +db-table-get
|
||||
|
||||
查看妙搭应用数据库某张表的结构。运行时命令事实以 `lark-cli apps +db-table-get --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
用于查看已知表的字段、索引、约束,或给 SQL/迁移生成提供依据。只想知道有哪些表时先 `+db-table-list`。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--app-id`、`--table`。
|
||||
- `--env` 枚举:`dev` / `online`,默认 `online`。
|
||||
- `--format pretty` 会向服务端请求 DDL,并直接输出 DDL 文本;默认 JSON 返回结构化 columns/indexes/constraints/stats。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +db-table-get --app-id app_xxx --table orders
|
||||
lark-cli apps +db-table-get --app-id app_xxx --table orders --env dev --format pretty
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 默认 JSON 读取 `data.name`、`columns`、`indexes`、`constraints`、`estimated_row_count`、`size_bytes`。
|
||||
- `--format pretty` stdout 是服务端返回的 DDL 文本,不是 JSON envelope;需要建表语句时可原样给用户。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
需要给用户看建表语句或迁移参照时用 `--format pretty`;需要程序化分析字段/索引/约束时保留默认 JSON。
|
||||
31
skills/lark-apps/references/lark-apps-db-table-list.md
Normal file
31
skills/lark-apps/references/lark-apps-db-table-list.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# apps +db-table-list
|
||||
|
||||
列出妙搭应用某个数据库环境的数据表。运行时命令事实以 `lark-cli apps +db-table-list --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
用于先摸清应用数据库里有哪些表,或在用户只给业务对象名时定位可能的表名。已知表名且要字段/索引时直接用 `+db-table-get`。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--app-id`。
|
||||
- `--env` 枚举:`dev` / `online`,默认 `online`。
|
||||
- 分页:`--page-size` 默认 20,`--page-token` 使用上一页 cursor。
|
||||
- pretty 输出列包含 `name`、`description`、`estimated_row_count`、`size`、`columns`(列数)。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +db-table-list --app-id app_xxx
|
||||
lark-cli apps +db-table-list --app-id app_xxx --env dev --page-size 50
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 成功读取 `data.items[]`;每项字段是 `name`、`description`、`estimated_row_count`、`size_bytes`、`column_count`(列数)。CLI 默认不透出每表完整 `columns[]`(与 `+db-table-get` 重复且放大 token),只给 `column_count`;要完整列定义/索引/约束用 `+db-table-get`。
|
||||
- pretty 输出是 5 列扫描表:`name`、`description`、`estimated_row_count`、`size`、`columns`(即列数)。
|
||||
- 若响应带 `has_more=true`,用返回的 `page_token` / `next_page_token` 翻页。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
用户说“本地/开发库/调试库”时优先 `--env dev`;线上问题排查用 `--env online`。如果 dev 返回服务端错误提示未初始化,多环境入口是 [`+db-env-create`](lark-apps-db-env-create.md)。
|
||||
35
skills/lark-apps/references/lark-apps-env-pull.md
Normal file
35
skills/lark-apps/references/lark-apps-env-pull.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# apps +env-pull
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。
|
||||
|
||||
把妙搭应用的启动期环境变量拉取到本地项目根的 `.env.local`。身份固定 `--as user`;scope `spark:app:read`。`--app-id` 必填,目标项目根默认当前工作目录(`--project-path` 可指定)。
|
||||
|
||||
## 何时别用(核心反模式)
|
||||
|
||||
**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并把用户刚改完的 `.env.local` 临时改动覆盖掉。
|
||||
|
||||
只在这些兜底场景用:
|
||||
|
||||
- 不通过 `npm run dev` 启动(直接跑 `node` / IDE debug)。
|
||||
- `.env.local` 被改坏 / 删除,想重新同步。
|
||||
|
||||
## 行为
|
||||
|
||||
- **合并、不清空**:写入 `.env.local` 时保留你手写的内容与注释——命中的 key 替换值,新 key 追加,不整体覆盖。
|
||||
- **安全护栏**:返回的 envelope **不会回显任何 env key / value**(防止 token / 数据库凭据泄漏到日志或 CI 输出)。要看实际值请直接读 `.env.local`。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +env-pull --app-id app_xxx
|
||||
```
|
||||
|
||||
## 失败处理
|
||||
|
||||
`missing_scope`(没拿到 `spark:app:read`)时,按 lark-shared 引导 `lark-cli auth login --domain apps`。其余失败优先转述 `error.hint` / `error.message`。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-apps](../SKILL.md) — 妙搭应用全部命令 + 心智模型
|
||||
- [lark-apps-local-dev](lark-apps-local-dev.md) — 本地全栈开发端到端流程
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
37
skills/lark-apps/references/lark-apps-git-credential.md
Normal file
37
skills/lark-apps/references/lark-apps-git-credential.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# apps Git credential
|
||||
|
||||
妙搭 Git 凭证用于本地原生 `git clone/pull/push`。运行时命令事实以 `lark-cli apps +git-credential-init --help`、`+git-credential-list --help`、`+git-credential-remove --help` 为准。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli apps +git-credential-init --app-id app_xxx
|
||||
lark-cli apps +git-credential-list
|
||||
lark-cli apps +git-credential-remove --app-id app_xxx
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- `+git-credential-init` 成功后读取 `data.repository_url`;不要展示或保存其中的凭据细节,只用于下一步 `git clone`。
|
||||
- `+git-credential-list` 返回本地记录和状态;可用来判断是否需要重新 init。
|
||||
- `+git-credential-remove` 只清本地配置;成功后告知不会删除云端应用或仓库。
|
||||
|
||||
## 行为规则
|
||||
|
||||
- `+git-credential-init` 返回 `repository_url`,并配置 URL-scoped Git credential helper。后续 clone/pull/push 使用原生 git。
|
||||
- `+git-credential-list` 列出本地已配置的妙搭 Git 凭证,不需要 `--app-id`。
|
||||
- `+git-credential-remove` 只移除本地凭证/helper,不删除云端应用或仓库。
|
||||
- 看到 Repository URL 后继续:
|
||||
|
||||
```bash
|
||||
git clone <repository_url>
|
||||
cd <repo>
|
||||
git checkout sprint/default
|
||||
```
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 不要手动打印、保存或拼接 token。
|
||||
- clone、pull、push、diff、log 等代码仓库操作都使用原生 `git`;不存在 `apps +pull` / `apps +push` / `apps code +read` 这类代码读写 shortcut,不要臆造。
|
||||
- 不要 push/force-push `main`;`main` 是发布态快照,由 `apps +release-create` 成功后服务端推进,直推/force-push 会被服务端护栏拒绝。
|
||||
- Git 认证失败、本地凭证损坏或 helper 缺失时,重新执行 `+git-credential-init --app-id <id>` 覆盖本地配置;不要让用户复制 token 到 remote URL。
|
||||
@@ -1,169 +1,51 @@
|
||||
# apps +html-publish
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
把本地 HTML 文件或静态目录发布为妙搭应用访问 URL。运行时命令事实以 `lark-cli apps +html-publish --help` 为准。
|
||||
|
||||
把本地的 HTML 文件或目录部署为可访问的妙搭应用,响应返回应用的访问链接 `url`。
|
||||
## 何时用
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 发布整个目录
|
||||
lark-cli apps +html-publish --app-id app_xxx --path ./dist/
|
||||
|
||||
# 发布单个 HTML 文件
|
||||
lark-cli apps +html-publish --app-id app_xxx --path ./index.html
|
||||
|
||||
# 预演(打印文件清单 + SHA256 + 目标 endpoint,不发请求)
|
||||
lark-cli apps +html-publish --app-id app_xxx --path ./dist --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--app-id <id>` | ✅ | 应用 ID。从 `apps +create` 响应里拿;或者从用户给的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取(详见 `../SKILL.md` "用户没给 app_id" 一节) |
|
||||
| `--path <path>` | ✅ | 本地文件或目录路径;目录会递归打包成 tar.gz。**必须含 `index.html`**:目录形态时根目录下,单文件形态时文件名必须就是 `index.html`(妙搭统一以 `index.html` 作为应用入口) |
|
||||
| `--allow-sensitive` | ❌ | 跳过 Validate 的凭据文件扫描(详见下面"凭据文件拦截"一节)。默认不传;仅在用户明示要发布凭据示例文件(如教程站的 `.env.example`)时才加 |
|
||||
|
||||
## 返回值
|
||||
|
||||
**成功:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"url": "https://miaoda.feishu.cn/app/app_4k5jepcbjmv6m"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**业务失败(如构建失败、应用不存在):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "api",
|
||||
"code": 90001,
|
||||
"message": "html-publish failed (code=90001): build failed: dependency conflict",
|
||||
"hint": "构建失败:用 `lark-cli apps +html-publish --path <path> --dry-run` 检查打包文件清单"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**基础设施失败(网络 / HTTP 5xx):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": { "type": "network", "message": "...", "hint": "" }
|
||||
}
|
||||
```
|
||||
|
||||
**Validate 失败(本地校验,如缺 --app-id):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": { "type": "validation", "message": "--app-id is required" }
|
||||
}
|
||||
```
|
||||
|
||||
## 字段语义
|
||||
|
||||
| 字段 / 组合 | 含义 |
|
||||
|---|---|
|
||||
| `data.url` 存在且无 `error` | 发布成功,URL 可访问 |
|
||||
| `error.type=api` | 业务失败(构建失败、应用不存在等),按 `hint` 引导用户修复 |
|
||||
| `error.type=network` | 网络 / 服务端 5xx,告诉用户稍后重试 |
|
||||
| `error.type=validation` | 本地参数错,提示用户修 flag |
|
||||
| `error.hint` 非空 | **优先转述给用户**,比 `error.message` 更可操作 |
|
||||
|
||||
## 典型场景
|
||||
|
||||
### 场景 1:用户说"把这个目录发布到妙搭"
|
||||
用于把已经存在的本地 HTML 文件或静态产物目录发布成妙搭访问 URL。它不负责生成 HTML 内容,也不负责全栈应用代码发布。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--app-id`、`--path`。
|
||||
- `--path` 可以是单个文件或目录;入口必须是 `index.html`。
|
||||
- 可选:`--allow-sensitive`,跳过凭据文件扫描。
|
||||
- 客户端会打包 tar.gz 并上传发布;压缩包上限当前为 20MB,未压缩候选文件总量也有保护上限。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +create --name "Demo" --app-type html
|
||||
lark-cli apps +html-publish --app-id app_xxx --path ./dist
|
||||
lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
|
||||
```
|
||||
|
||||
成功后:
|
||||
## 输出契约
|
||||
|
||||
> 应用发布成功!访问 `{url}` 查看。
|
||||
- 成功默认 JSON envelope 只关心 `data.url`;这是本轮 HTML 发布后的发布态访问链接。
|
||||
- pretty 输出为 `url: <url>`,适合人看;自动化取字段用 JSON 或 `--jq '.data.url'`。
|
||||
- 业务失败如构建失败、应用不存在通常带 `error.hint`;优先转述 hint。网络/服务端失败则建议稍后重试。
|
||||
|
||||
可选追加:
|
||||
## 链接边界
|
||||
|
||||
> 如需让其他人访问,可以用 `apps +access-scope-set` 设置可用范围。
|
||||
- 开发态链接可由 `app_id` 拼出:`https://miaoda.feishu.cn/app/{app_id}`,用于进入妙搭编辑/开发态。
|
||||
- 发布态访问链接以本命令成功返回的 `data.url` 为准。
|
||||
- 重新发布前,`+list` 的 `is_published=true` 只能说明历史上发布过,不代表当前本地产物已经部署。
|
||||
|
||||
### 场景 2:用户没有 app_id
|
||||
## 预览与发布边界
|
||||
|
||||
```bash
|
||||
APP=$(lark-cli apps +create --name "..." --app-type HTML -q '.data.app.app_id' | tr -d '"')
|
||||
lark-cli apps +html-publish --app-id "$APP" --path ./dist
|
||||
```
|
||||
- 用户只说“用 HTML 写个 PPT/页面给我看看”时,先生成本地文件或目录,返回路径并问是否发布到妙搭分享;不要默认创建应用或部署。
|
||||
- 用户明确说“部署出去/发链接/可分享”时,才创建 `html` 应用并用 `+html-publish`。
|
||||
- 用户要发布但没有 app_id 时,先 `+create --app-type html` 创建应用;应用名可从页面/站点主题生成,不要让用户手动提供 app_id。
|
||||
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist`,不要把 `.git`、`node_modules`、源码缓存一起带上。
|
||||
- 重新部署同一个 HTML 应用时复用原 `app_id`,只重新执行 `+html-publish --app-id <id> --path <dir-or-index.html>`。
|
||||
|
||||
### 场景 3:构建失败(code=90001)
|
||||
## 安全规则
|
||||
|
||||
转述 hint:
|
||||
默认会拦截 `.env`、`.npmrc`、`.aws/credentials` 等凭据文件。只有用户明确要发布凭据示例文件或教程内容时,才追加 `--allow-sensitive`;追加前先说明将包含哪些敏感候选文件。
|
||||
|
||||
> 构建失败,建议用 `lark-cli apps +html-publish --app-id <your-app-id> --path ./dist --dry-run` 看一下打包文件清单是否完整。
|
||||
## 常见失败
|
||||
|
||||
### 场景 4:应用不存在(code=90002)
|
||||
|
||||
> hint:"应用不存在或无权访问;请用户确认妙搭应用链接 / app_id 是否正确(从 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面取)"
|
||||
|
||||
转述给用户。
|
||||
|
||||
### 场景 5:网络 / 服务端失败(type=network)
|
||||
|
||||
> 服务暂时不可用,建议稍后重试。
|
||||
|
||||
## 凭据文件拦截
|
||||
|
||||
Validate 阶段会扫描 `--path` 下所有候选文件,命中以下任一模式 **直接 exit 非 0**(dry-run 和真发都拦,不再是 advisory warning):
|
||||
|
||||
- `.env` / `.env.*`(环境变量 / API key)
|
||||
- `.npmrc` / `.netrc`(HTTP 凭据)
|
||||
- `.git-credentials`(Git over HTTPS 凭据)
|
||||
- `.aws/credentials`、`.docker/config.json`、`.kube/config`(云 SDK 凭据)
|
||||
|
||||
报错形态:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "validation",
|
||||
"message": "--path contains 1 credential file(s) that should not be published: dist/.env",
|
||||
"hint": "remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Agent 行为契约**:
|
||||
|
||||
- 默认必须从产物里清掉命中的文件后再 publish
|
||||
- 只有当用户**明确**意图是 shipping 凭据示例(文档 / 教程站等)时,才追加 `--allow-sensitive` 旁路;旁路时 dry-run 会在 `sensitive_waived` 字段列出被放行的文件名,转述给用户确认
|
||||
|
||||
不在拦截范围内(旧版扫过、新版**不再**扫):`.git/` SCM 历史、SSH 私钥 `id_rsa*` / `id_ed25519*` 等、`*.pem` / `*.key`、`.aws/config`。如果产物里有这些文件且确实敏感,要靠用户自己保持产物目录干净。
|
||||
|
||||
## 提示
|
||||
|
||||
- `--path` 既可以是 cwd(`.`)也可以是子目录或单文件;**不再硬拒 cwd**,cwd 干净(没有命中上面凭据列表)就能发。仍然建议传具体子目录(`./dist`、`./public/` 等)以减少误打包风险
|
||||
- `--path` **必须**是 cwd 内的相对路径(如 `./dist`、`./index.html`);绝对路径或越界路径(`../`、`/Users/...`)CLI 会直接拒绝。需要发布 cwd 外的目录时,先切到 agent 工作目录再调,**不要**私自 `cd` 绕过
|
||||
- 目录打包成 tar.gz 时**不做过滤**(`.git` / `node_modules` 等会一并打包,只有上面那张凭据 list 才会被 Validate 拦),让用户传干净的产物目录(如 `./dist`)
|
||||
- 旁路写法:`apps +html-publish --app-id <id> --path <path> --allow-sensitive`
|
||||
- **不要**原样把 envelope JSON 转述给用户
|
||||
|
||||
## 协同命令
|
||||
|
||||
| 场景 | 命令 |
|
||||
|---|---|
|
||||
| 创建新应用 | `apps +create` |
|
||||
| 设置可用范围 | `apps +access-scope-set` |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-apps](../SKILL.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
- 缺少 `index.html`:目录根放置 `index.html`,或单文件路径直接指向名为 `index.html` 的文件。
|
||||
- 包体过大:让用户精简 `--path`,不要把源码、依赖目录、构建缓存一起发布。
|
||||
|
||||
36
skills/lark-apps/references/lark-apps-init.md
Normal file
36
skills/lark-apps/references/lark-apps-init.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# apps +init
|
||||
|
||||
`+init` 初始化妙搭应用的代码(clone 仓库、scaffold/同步源码、拉取本地环境变量)。运行时命令事实以 `lark-cli apps +init --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
用于把妙搭全栈应用源码拉到本地并准备开发环境。用户只是要云端 Agent 生成应用时,不要初始化本地仓库。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--app-id`。
|
||||
- 可选:`--dir`,clone 目标目录;省略时默认 `./<app-id>`。
|
||||
- 可选:`--template`,空仓库脚手架模板;省略时当前回退 `nestjs-react-fullstack`。
|
||||
- 固定 checkout 分支:`sprint/default`。
|
||||
- `+init` 会初始化 Git 凭证、clone 仓库、切到工作分支并生成/同步本地项目。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +init --app-id app_xxx --dir ./my-app
|
||||
lark-cli apps +init --app-id app_xxx --dir /absolute/path/my-app --template nestjs-react-fullstack
|
||||
lark-cli apps +init --app-id app_xxx --dir ./my-app --dry-run
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 真跑时 stdout 是 JSON envelope;stderr 会有 `->` / `→` 进度行。成功读 stdout,失败解析 stderr 末尾的 JSON 错误。
|
||||
- 成功普通初始化读取 `data.clone_path`、`branch`、`committed`、`pushed`;`repository_url` 已脱敏,不要当凭据使用。
|
||||
- `scaffold=already_initialized` 表示目录已初始化:跳过 clone/scaffold/commit,但仍会执行一次 env-pull 刷新本地环境变量(输出含 `env_pulled`,成功时含 `env_file`,失败时含 `env_pull_error` 且退出码仍为 0);此时通常没有 `repository_url` / `branch`。
|
||||
- `--dry-run` 只打印计划,不执行 git / npx;若输出含 `dir_error`,真跑前先让用户换目录。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 目标目录必须不存在、为空目录,或已含 `.spark/meta.json` 的已初始化仓库。
|
||||
- 目标目录已含 `.spark/meta.json` 时,`+init` 会跳过 clone/scaffold,但仍执行一次 env-pull 刷新本地环境变量;告知用户“仓库已初始化,本地环境变量已刷新,可直接开发”,不要误报失败或重复 clone。
|
||||
- `+init` 输出没有必要原样复述;告诉用户 clone path、分支和下一步即可。
|
||||
@@ -1,95 +1,37 @@
|
||||
# apps +list
|
||||
|
||||
> **⚠️ Hidden 命令(`Hidden: true`)—— 不对 Agent 暴露**:本命令从 `--help` / tab completion / SKILL.md 的 Shortcuts 表中隐去,**Agent 不应主动调用**。
|
||||
>
|
||||
> 需要拿现有应用的 `app_id` 时让用户提供 **妙搭应用链接**(如 `https://miaoda.feishu.cn/app/app_xxxxxxxxxxxxx`)然后从 URL 中提取,或者让用户直接给 `app_id` 字符串。详见 [`../SKILL.md`](../SKILL.md) "用户没给 app_id" 一节。
|
||||
>
|
||||
> 本文件保留是因为命令仍然功能可用(手动调用),下面内容仅供人类参考。
|
||||
列出当前用户可见的妙搭应用,用于从应用名定位 `app_id`。运行时命令事实以 `lark-cli apps +list --help` 为准。
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
## 何时用
|
||||
|
||||
列出当前用户名下的妙搭应用。**cursor 分页**:默认拉一页(`--page-size 20`),通过 `--page-token` 拉下一页。
|
||||
在下游操作需要 `app_id`、而用户只给了应用名/描述时,用 `--keyword` 定位。无明确目的的全量枚举会浪费上下文,优先按关键词缩小范围。
|
||||
|
||||
## 命令
|
||||
## 命令骨架
|
||||
|
||||
- 支持 `--keyword` 按应用名模糊搜索。
|
||||
- `--ownership` 枚举:`all` / `mine` / `shared`(默认 `all` = 我创建的 + 共享给我的;`mine` = 仅我创建;`shared` = 仅共享给我)。
|
||||
- `--app-type` 枚举:`html` / `full_stack`。
|
||||
- 分页:`--page-size` 默认 20,`--page-token` 传上一页 cursor。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
# 拉第一页(默认 page_size=20)
|
||||
lark-cli apps +list
|
||||
|
||||
# 自定义页大小
|
||||
lark-cli apps +list --page-size 50
|
||||
|
||||
# 翻页(拿上一次响应的 page_token)
|
||||
lark-cli apps +list --page-token "eyJQaW5PcmRlciI6..."
|
||||
|
||||
# 取 ID 列表(脚本场景)
|
||||
lark-cli apps +list -q '.data.items[].app_id'
|
||||
|
||||
# 按名字找 app_id
|
||||
lark-cli apps +list -q '.data.items[] | select(.name=="客户调研问卷") | .app_id'
|
||||
lark-cli apps +list --keyword "审批"
|
||||
lark-cli apps +list --ownership mine --app-type full_stack
|
||||
lark-cli apps +list --page-token "<cursor>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
## 输出契约
|
||||
|
||||
| 参数 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `--page-size <int>` | ❌ | `20` | 每页条数 |
|
||||
| `--page-token <str>` | ❌ | `""` | 翻页 cursor,从上次响应的 `data.page_token` 拿 |
|
||||
- 成功读取 `data.items[]`;保留字段为 `description`、`app_id`、`name`、`is_published`、`online_url`、`updated_at`,用于候选展示的核心字段是 `name`、`app_id`、`updated_at`。
|
||||
- `is_published=true` 只代表应用历史上有发布版本,不代表最新云端会话、最新代码提交或最新 HTML 产物已经部署。
|
||||
- `online_url` 是当前已有发布态入口;若你没有在本轮确认发布完成,不要把它描述成“最新版本链接”。
|
||||
- 默认输出已裁掉 `icon_url`(图片 URL,agent 无法渲染)和 `created_at`(与 `updated_at` 冗余);需要时可用 `--jq` 过滤上述保留字段。
|
||||
- `data.items` 可能为空;不要把空列表当失败。
|
||||
- 若有 `has_more=true`,用返回的 `page_token` / `next_page_token` 继续翻页。
|
||||
|
||||
## 返回值
|
||||
## Agent 规则
|
||||
|
||||
**成功:**
|
||||
多候选时展示名称、app_id、updated_at 让用户确认。用户描述里已经有 `app_xxx` 或妙搭链接时,直接提取,不再 `+list`。
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"app_id": "app_4k5jepcbjmv6m",
|
||||
"name": "客户调研问卷",
|
||||
"description": "...",
|
||||
"icon_url": "...",
|
||||
"created_at": "2026-05-18T10:00:00Z",
|
||||
"updated_at": "2026-05-18T10:05:00Z"
|
||||
}
|
||||
],
|
||||
"page_token": "cursor_next_xxx",
|
||||
"has_more": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**成功(空列表):**
|
||||
|
||||
```json
|
||||
{ "ok": true, "data": { "items": [], "has_more": false } }
|
||||
```
|
||||
|
||||
**失败:**
|
||||
|
||||
```json
|
||||
{ "ok": false, "error": { "type": "api", "message": "...", "hint": "..." } }
|
||||
```
|
||||
|
||||
## 字段语义
|
||||
|
||||
- `data.items` 长度可能为 0(用户没建过应用)
|
||||
- `data.has_more=true` 表示还有下一页;用 `data.page_token` 作为下次 `--page-token` 传入
|
||||
- `data.has_more=false` 且 `data.page_token` 为空 / 缺省表示已经到末尾
|
||||
|
||||
## 用途
|
||||
|
||||
本命令保留可供人类操作员手动调用(例如运维 / 调试场景,按 `name` 搜应用 ID)。**Agent 不应主动调用**:默认行为是 `apps +create` 新建;要复用现有应用,**让用户给妙搭应用链接或 app_id**,详见 [`../SKILL.md`](../SKILL.md) "用户没给 app_id" 一节。
|
||||
|
||||
## 协同命令
|
||||
|
||||
| 场景 | 命令 |
|
||||
|---|---|
|
||||
| 创建新应用 | `apps +create` |
|
||||
| 修改应用 | `apps +update` |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-apps](../SKILL.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
把 `+list` 当定位工具和发布态快照工具,不要把 `is_published` 当部署完成证明。需要证明“最新内容已上线”时,使用对应发布命令的完成状态:全栈看 `+release-get` 的 `finished`,HTML 看 `+html-publish` 的成功返回。
|
||||
|
||||
76
skills/lark-apps/references/lark-apps-local-dev.md
Normal file
76
skills/lark-apps/references/lark-apps-local-dev.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# lark-apps 本地全栈开发
|
||||
|
||||
适用:用户要把妙搭全栈应用源码拉到本地,用本地 code agent/IDE 开发、调试数据库,再发布。
|
||||
|
||||
## 新建 vs 已有应用
|
||||
|
||||
新建还是修改已有,由上方入口(SKILL.md「选择开发路径」)判定;进到本地流程后按分支走:
|
||||
|
||||
- **新建**:从 `+create` 开始走下面的端到端流程。
|
||||
- **已有应用**(本地还没有源码):跳过 `+create`,先按下方「存量应用入口」拿 `app_id`,再 `+init`(或 `+git-credential-init` + `git clone`)把它拉到本地,然后照常开发。
|
||||
|
||||
## 端到端流程(新建应用)
|
||||
|
||||
`+create(full_stack)` -> `+init`(或手动 `+git-credential-init` + `git clone`)-> `npm install && npm run dev` -> 按需 `+db-*` 调库 -> `git add` + `git commit`(提交本次改动)-> `git push origin sprint/default` -> `+release-create` -> `+release-get`。
|
||||
|
||||
```bash
|
||||
# 新建 full_stack 应用
|
||||
lark-cli apps +create --name "审批系统" --app-type full_stack \
|
||||
--description "支持登录、提交申请、多级审批、状态查询"
|
||||
|
||||
# 初始化本地仓库(--dir 取值见下方「领域规则」,勿照抄此处示例值)
|
||||
lark-cli apps +init --app-id app_xxx --dir ./approval-app
|
||||
|
||||
# 进入仓库后按项目脚手架启动
|
||||
cd ./approval-app
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# 开发完成后:提交本次改动 -> git push origin sprint/default -> +release-create。
|
||||
# +release-create 部署的是远端 sprint/default 上已 push 的代码,不是本地工作区——没 commit + push 的改动不会进入发布。
|
||||
git add <本次开发的文件> # 提交粒度见下方「改完代码后部署上线」
|
||||
git commit -m "feat: ..."
|
||||
git push origin sprint/default
|
||||
lark-cli apps +release-create --app-id app_xxx
|
||||
```
|
||||
|
||||
`+init` 是推荐便捷入口;想逐步手动控制时,先 `+git-credential-init` 拿 `repository_url`,再用原生 `git clone` / `git checkout sprint/default`。
|
||||
|
||||
## 改完代码后部署上线
|
||||
|
||||
已拉到本地、改完代码,用户说"推上去""部署""上线""发布到云端"时,按此序列。
|
||||
|
||||
> `+release-create` 部署的是远端 `sprint/default` 上**已 push** 的代码,不是你本地工作区——未 commit / 未 push 的改动不会进入这次发布。所以发布前务必先把本次改动提交并推送。
|
||||
|
||||
1. `git status` 看本次改动;`git add <本次相关文件>` 暂存后 `git commit` 提交。只提交本次任务相关的改动即可,无关的零散文件不必强求清空——发布门禁是「**本次相关改动已提交并推送**」,不是「工作区绝对干净」。
|
||||
2. `git push origin sprint/default` 把工作分支推到云端(遇非 fast-forward:先 `git pull --rebase origin sprint/default` 解决冲突再推,绝不 force-push)。
|
||||
3. `lark-cli apps +release-create --app-id <app_id>` 发起部署上线,记下返回的 `release_id`。
|
||||
4. `lark-cli apps +release-get --app-id <app_id> --release-id <release_id>` 轮询:`publishing` 继续轮询;`finished` 成功时该命令输出已含 `online_url`,直接读取它返回给用户(这是本轮发布完成后的可分享链接),无需再调 `+list`;`failed` 时该命令输出已含 `error_logs`,直接据此给出失败原因(`+list` 仅作独立查询入口)。
|
||||
|
||||
## 领域规则
|
||||
|
||||
- 代码读写走原生 `git`;CLI 负责凭证、初始化、发布和数据库调试。不存在 `apps +pull` / `apps +push` / `apps code +read` 这类代码读写 shortcut,不要臆造。
|
||||
- `+init` 会编排 `+git-credential-init`、`git clone`、切到 `sprint/default`、运行脚手架,并在有变更时提交/推送。
|
||||
- `+init --dir` 选目录:用户已预授权或表达"不要询问"(见 SKILL.md「预授权判定」)→ 按应用名派生 `./<app-name>` 直接传 `--dir`、不停问;否则先问用户用哪个目录再传。目标已存在/非空时回问换目录。
|
||||
- `sprint/default` 是工作分支;`main` 是发布态快照,由 `+release-create` 成功后服务端 fast-forward 推进;服务端护栏禁直推 `main`、拒 force-push、要求 `sprint/default` fast-forward。
|
||||
- 已拉到本地后,pull/push/diff/log 都用原生 git;云端 `sprint/default` 比本地新时,先 `git pull --rebase origin sprint/default`,解决冲突后再 push 和 publish。
|
||||
- 环境变量由脚手架在本地启动时处理;需要手动刷新时用 `+env-pull`。
|
||||
- DB 调试用 `+db-table-list` / `+db-table-get` / `+db-execute`;不要裸连数据库或自行拼连接串。
|
||||
- DB 分 `dev` / `online`;日常调试优先 `--env dev`。dev 的库结构变更要上线时,仍按应用发布链路走 `+release-create`,不要另造“数据库发布”步骤。
|
||||
- 存量单库应用需要 dev/online 多环境时,用 `+db-env-create --env dev`。这是不可逆 high-risk 操作。
|
||||
- 只从 `+list` 看到 `is_published=true`,不能证明本地刚推送的代码已经部署;必须有本轮 `+release-get finished`。
|
||||
|
||||
## 存量应用入口
|
||||
|
||||
已有项目目录先读 `.spark/meta.json` 取 `app_id`;没有本地项目但知道应用名时用:
|
||||
|
||||
```bash
|
||||
lark-cli apps +list --keyword "应用名"
|
||||
```
|
||||
|
||||
拿到 `app_id` 后再 `+init` 或 `+git-credential-init`。
|
||||
|
||||
## 何时不用
|
||||
|
||||
- 用户只想发布现成 HTML / 静态目录拿分享链接:读 [`lark-apps-html-publish.md`](lark-apps-html-publish.md)。
|
||||
- 用户明确要云端妙搭 Agent 生成/迭代,而不是本地写代码:读 [`lark-apps-cloud-dev.md`](lark-apps-cloud-dev.md)。
|
||||
30
skills/lark-apps/references/lark-apps-release-create.md
Normal file
30
skills/lark-apps/references/lark-apps-release-create.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# apps +release-create
|
||||
|
||||
为妙搭应用创建发布 release。运行时命令事实以 `lark-cli apps +release-create --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
用于把全栈应用的代码分支推进到发布流程。它不是 HTML 静态发布入口;本地 `index.html` / `dist` 要读 [`lark-apps-html-publish.md`](lark-apps-html-publish.md)。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--app-id`。
|
||||
- 可选:`--branch`;省略时服务端使用默认发布分支。
|
||||
- 返回 `release_id` 和 `status`,后续用 `+release-get` 轮询。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +release-create --app-id app_xxx
|
||||
lark-cli apps +release-create --app-id app_xxx --branch sprint/default --dry-run
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 成功读取 `data.release_id` 和 `data.status`;`release_id` 是后续 `+release-get` 的入参。
|
||||
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询。
|
||||
- `+release-create` 返回 release 只代表发布已发起。只有 `+release-get` 对同一个 `release_id` 返回 `finished` 后,才能说本轮最新版本已部署。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
`+release-create` 部署的是远端 `sprint/default` 上已 push 的代码,不是本地工作区——本地若有你修改但未推送的改动,需要先 `git add` + `git commit` 并 `git push` 到 `sprint/default`,否则这些改动不会进入这次发布。发布后若 status 是 `publishing`,用 [`+release-get`](lark-apps-release-get.md) 查询。`+release-create` 部署上线属高影响动作——作为别的命令的连带前置时,按 SKILL.md「高影响动作:确认与预授权」先征得用户同意再发布。
|
||||
28
skills/lark-apps/references/lark-apps-release-get.md
Normal file
28
skills/lark-apps/references/lark-apps-release-get.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# apps +release-get
|
||||
|
||||
按 release ID 查询单次发布详情。运行时命令事实以 `lark-cli apps +release-get --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
用于跟进已知 `release_id` 的发布状态。没有 `release_id` 时先读 [`lark-apps-release-list.md`](lark-apps-release-list.md),不要让用户手填。
|
||||
|
||||
`release_id` 是妙搭发布 ID(`+release-create` 返回),不是飞书审批实例号;查发布进度/失败都在 `apps +release-*` 命令族内完成,不要路由到 lark-approval。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--app-id`、`--release-id`。
|
||||
- `release_id` 来自 `+release-create` 或 `+release-list`。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +release-get --app-id app_xxx --release-id release_yyy
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 成功可能直接返回 release 字段,也可能包在 `data.release`;读取 `release_id`、`status`、`created_at`、`updated_at`,以及 `commit_id`(本轮发布对应的 git commit SHA,pretty 输出在其非空时展示一行)。
|
||||
- `status=publishing` 继续轮询。此时尚无 `online_url`;不要拿其它链接(如 `+list` 里的应用主页 / 开发态预览 URL)冒充"本轮发布的访问链接"——只回报 `release_id`、`status`,并说明 `finished` 后才有 `online_url`。
|
||||
- `status=finished` 发布成功——**本命令输出已含 `online_url`,直接读取它作为本轮发布的线上访问链接**返回用户,无需再调 `+list`(`+list` 仍可用于按应用名浏览,但不是发布主流程的必经步骤)。
|
||||
- `status=failed` 发布失败——**本命令输出已含 `error_logs`(`step`/`error_log`),直接据此向用户转述关键失败步骤和可行动修复**。
|
||||
- 只有当这个 `release_id` 已返回 `finished`,随后读到的 `online_url` 才能被表述为"本轮发布后的访问链接"。单独从 `+list` 看到 `is_published=true` 不能证明最新版本已部署。
|
||||
31
skills/lark-apps/references/lark-apps-release-list.md
Normal file
31
skills/lark-apps/references/lark-apps-release-list.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# apps +release-list
|
||||
|
||||
分页查询妙搭应用发布历史,最新发布在前。运行时命令事实以 `lark-cli apps +release-list --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
用户问"最近发布""历史版本""上次为什么失败",但没有提供 `release_id` 时使用。拿到候选 release 后再接 `+release-get`。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--app-id`。
|
||||
- 可选 `--status`:`publishing` / `finished` / `failed`。
|
||||
- 可选 `--page-size`:默认 20,最大 500;总是发送给服务端。
|
||||
- 可选 `--page-token`:上一页 cursor。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +release-list --app-id app_xxx --page-size 10
|
||||
lark-cli apps +release-list --app-id app_xxx --status failed
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 成功读取 `data.releases[]`;关键字段是 `release_id`、`status`、`created_at`、`updated_at`。
|
||||
- `release_id` 用于继续查 `+release-get`。
|
||||
- 若 `has_more=true`,用 `next_page_token` / `page_token` 翻页。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
用户限定只看 N 条("最近 N 条""最新 N 个""只要前 N 条")时用 `--page-size N`(如"最近一次发布"→ `--page-size 1`),而不是取全量再本地截断。
|
||||
@@ -1,88 +1,30 @@
|
||||
# apps +update
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
部分更新妙搭应用元信息。运行时命令事实以 `lark-cli apps +update --help` 为准。
|
||||
|
||||
部分更新一个妙搭应用的元信息(名字 / 描述)。**只把传入的字段发给服务端,未传字段保持不变**。
|
||||
## 何时用
|
||||
|
||||
## 命令
|
||||
只更新应用展示元信息。用户要改代码、发布内容、可见范围或数据库时,不走 `+update`。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--app-id`。
|
||||
- 至少提供一个:`--name` 或 `--description`。
|
||||
- 只发送用户提供的字段,不会清空未提供字段。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +update --app-id app_xxx --name "调研问卷 v2"
|
||||
lark-cli apps +update --app-id app_xxx --description "新描述"
|
||||
lark-cli apps +update --app-id app_xxx --name "v2" --description "新描述"
|
||||
lark-cli apps +update --app-id app_xxx --name "审批系统"
|
||||
lark-cli apps +update --app-id app_xxx --description "用于部门审批流转"
|
||||
lark-cli apps +update --app-id app_xxx --name "审批系统" --description "用于部门审批流转" --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
## 输出契约
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--app-id <id>` | ✅ | 应用 ID |
|
||||
| `--name <str>` | ❌ | 新名字 |
|
||||
| `--description <str>` | ❌ | 新描述 |
|
||||
- 成功读取 `data.app`;响应是完整应用对象,不只是被修改字段。
|
||||
- 缺 `--app-id` 或没有提供 `--name` / `--description` 会在本地 validation 失败。
|
||||
|
||||
`--name` 和 `--description` 至少传一个,否则 Validate 阶段报错。
|
||||
## Agent 规则
|
||||
|
||||
## 返回值
|
||||
|
||||
**成功:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"app": {
|
||||
"app_id": "app_4k5jepcbjmv6m",
|
||||
"name": "调研问卷 v2",
|
||||
"description": "...",
|
||||
"icon_url": "https://lf3-static.bytednsdoc.com/.../feisuda/avatar/5.svg",
|
||||
"created_at": "2026-05-18T10:00:00Z",
|
||||
"updated_at": "2026-05-18T10:05:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": { "type": "api", "message": "...", "hint": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
## 字段语义
|
||||
|
||||
- 响应 `data.app` 含完整应用对象(所有字段),不只是被改的
|
||||
- `created_at` / `updated_at` 都是 ISO 8601 UTC 时间字符串
|
||||
- 失败时优先转述 `error.hint`
|
||||
|
||||
## 典型场景
|
||||
|
||||
### 场景 1:用户说"把应用 X 改名叫 Y"
|
||||
|
||||
```bash
|
||||
lark-cli apps +update --app-id app_xxx --name "Y"
|
||||
```
|
||||
|
||||
> 应用 `{app_id}` 已更新,新名字「{name}」。
|
||||
|
||||
### 场景 2:缺 `--app-id` 或没传可更新字段
|
||||
|
||||
Validate 直接拦截,提示用户加 flag。
|
||||
|
||||
### 场景 3:失败处理
|
||||
|
||||
转述 `error.hint` / `error.message`。
|
||||
|
||||
## 协同命令
|
||||
|
||||
| 场景 | 命令 |
|
||||
|---|---|
|
||||
| 找 app_id | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md`) |
|
||||
| 创建新应用 | `apps +create` |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-apps](../SKILL.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
更新前复述要变更的字段;用户没有提到的字段不要补默认值。执行后只转述新的名称/描述和 app_id,不需要展开原始响应。
|
||||
|
||||
@@ -5,7 +5,6 @@ package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -29,7 +28,7 @@ func TestAppsCreateDryRun(t *testing.T) {
|
||||
Args: []string{
|
||||
"apps", "+create",
|
||||
"--name", "Demo",
|
||||
"--app-type", "HTML",
|
||||
"--app-type", "html",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
@@ -40,7 +39,7 @@ func TestAppsCreateDryRun(t *testing.T) {
|
||||
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String())
|
||||
assert.Equal(t, "HTML", gjson.Get(result.Stdout, "api.0.body.app_type").String())
|
||||
assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.body.app_type").String())
|
||||
// Optional fields stay omitted when not provided.
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.body.description").Exists())
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.body.icon_url").Exists())
|
||||
@@ -54,7 +53,7 @@ func TestAppsCreateDryRun(t *testing.T) {
|
||||
Args: []string{
|
||||
"apps", "+create",
|
||||
"--name", "Demo",
|
||||
"--app-type", "HTML",
|
||||
"--app-type", "html",
|
||||
"--description", "survey app",
|
||||
"--icon-url", "https://example.com/icon.svg",
|
||||
"--dry-run",
|
||||
@@ -65,7 +64,7 @@ func TestAppsCreateDryRun(t *testing.T) {
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String())
|
||||
assert.Equal(t, "HTML", gjson.Get(result.Stdout, "api.0.body.app_type").String())
|
||||
assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.body.app_type").String())
|
||||
assert.Equal(t, "survey app", gjson.Get(result.Stdout, "api.0.body.description").String())
|
||||
assert.Equal(t, "https://example.com/icon.svg", gjson.Get(result.Stdout, "api.0.body.icon_url").String())
|
||||
})
|
||||
@@ -77,7 +76,7 @@ func TestAppsCreateDryRun(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"apps", "+create",
|
||||
"--app-type", "HTML",
|
||||
"--app-type", "html",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
@@ -98,7 +97,7 @@ func TestAppsCreateDryRun(t *testing.T) {
|
||||
Args: []string{
|
||||
"apps", "+create",
|
||||
"--name", " ",
|
||||
"--app-type", "HTML",
|
||||
"--app-type", "html",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
@@ -142,13 +141,15 @@ func TestAppsCreateDryRun(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
msg := validateErrorMessage(result)
|
||||
assert.Contains(t, msg, "not supported")
|
||||
assert.Contains(t, msg, "HTML")
|
||||
assert.Contains(t, msg, "invalid value")
|
||||
assert.Contains(t, msg, "full_stack")
|
||||
})
|
||||
|
||||
t.Run("RejectsLowercaseAppType", func(t *testing.T) {
|
||||
// app-type is case-sensitive; lowercase "html" must be rejected even though
|
||||
// it differs from the allowed "HTML" by case alone.
|
||||
t.Run("RejectsLegacyUppercaseAppType", func(t *testing.T) {
|
||||
// --app-type is a strict lowercase enum (html / full_stack); the CLI does
|
||||
// not normalize case. Legacy uppercase "HTML" is rejected — backend
|
||||
// compatibility for legacy values is a server concern the client does not
|
||||
// surface.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -156,7 +157,7 @@ func TestAppsCreateDryRun(t *testing.T) {
|
||||
Args: []string{
|
||||
"apps", "+create",
|
||||
"--name", "Demo",
|
||||
"--app-type", "html",
|
||||
"--app-type", "HTML",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
@@ -164,7 +165,7 @@ func TestAppsCreateDryRun(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
msg := validateErrorMessage(result)
|
||||
assert.True(t, strings.Contains(msg, `"html"`) && strings.Contains(msg, "not supported"),
|
||||
"expected case-sensitive rejection, got: %s", msg)
|
||||
assert.Contains(t, msg, "invalid value")
|
||||
assert.Contains(t, msg, "HTML")
|
||||
})
|
||||
}
|
||||
|
||||
50
tests/cli_e2e/apps/apps_db_env_create_dryrun_test.go
Normal file
50
tests/cli_e2e/apps/apps_db_env_create_dryrun_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestAppsDBEnvCreateDryRun pins +db-env-create URL `/apps/{app_id}/db_dev_init` 和 sync_data body 透传。
|
||||
// Risk: high-risk-write 在 dry-run 下不需要 --yes 确认。
|
||||
func TestAppsDBEnvCreateDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
t.Run("DefaultSyncDataFalse", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/db_dev_init", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "false", gjson.Get(result.Stdout, "api.0.body.sync_data").String())
|
||||
})
|
||||
|
||||
t.Run("SyncDataTrue", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
assert.Equal(t, "true", gjson.Get(result.Stdout, "api.0.body.sync_data").String())
|
||||
})
|
||||
}
|
||||
68
tests/cli_e2e/apps/apps_db_execute_dryrun_test.go
Normal file
68
tests/cli_e2e/apps/apps_db_execute_dryrun_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestAppsDBExecuteDryRun pins +db-execute 复用存量 URL,CLI 永远走 DBA 模式
|
||||
// (?transactional=false),sql body 由 --sql 透传,默认 env=dev。
|
||||
func TestAppsDBExecuteDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
t.Run("DefaultEnvIsDevAndTransactionalFalse", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/sql_commands", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "SELECT 1", gjson.Get(result.Stdout, "api.0.body.sql").String())
|
||||
assert.Equal(t, "false", gjson.Get(result.Stdout, "api.0.params.transactional").String(),
|
||||
"CLI is DBA mode → must send transactional=false in query")
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.body.transactional").Exists(),
|
||||
"transactional should be in query, not body")
|
||||
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String(),
|
||||
"default env must be dev (not production)")
|
||||
})
|
||||
|
||||
t.Run("OnlineEnvSwitch", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--env", "online", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.params.env").String())
|
||||
})
|
||||
|
||||
t.Run("RejectsEmptySQL", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", " ", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.ExitCode, "empty --sql must fail validation")
|
||||
})
|
||||
}
|
||||
82
tests/cli_e2e/apps/apps_db_table_get_dryrun_test.go
Normal file
82
tests/cli_e2e/apps/apps_db_table_get_dryrun_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestAppsDBTableGetDryRun pins +db-table-get 复用存量 URL。
|
||||
// 没有独立 --ddl flag —— 由 --format 同时驱动 CLI 渲染和 server 请求形态:
|
||||
//
|
||||
// --format pretty → CLI 给 server 带 ?format=ddl
|
||||
// --format json / table / ndjson / csv(含默认)→ CLI 不传 format query
|
||||
func TestAppsDBTableGetDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
t.Run("DefaultFormatJSONOmitsFormatQuery", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-table-get", "--app-id", "app_x", "--table", "orders", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables/orders", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.params.format").Exists(),
|
||||
"default (json) should omit format query")
|
||||
})
|
||||
|
||||
t.Run("PrettyFormatSendsFormatDDL", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
// pretty 模式 dry-run 输出是 plain text 列表(非 JSON envelope),用 substring 校验 query。
|
||||
assert.Contains(t, result.Stdout, "format=ddl",
|
||||
"--format pretty must trigger ?format=ddl")
|
||||
})
|
||||
|
||||
t.Run("TableFormatOmitsFormatQuery", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "table", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.params.format").Exists())
|
||||
})
|
||||
|
||||
t.Run("RequiresTableFlag", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-table-get", "--app-id", "app_x", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.ExitCode, "missing --table must fail")
|
||||
})
|
||||
}
|
||||
74
tests/cli_e2e/apps/apps_db_table_list_dryrun_test.go
Normal file
74
tests/cli_e2e/apps/apps_db_table_list_dryrun_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestAppsDBTableListDryRun pins +db-table-list 复用存量 URL(/apps/{app_id}/tables,
|
||||
// 不带 /db/),cursor 分页参数与 env 透传,且不发 include_stats query。
|
||||
func TestAppsDBTableListDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
t.Run("DefaultsToOnlineAndPageSize20", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-table-list", "--app-id", "app_x", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.params.env").String())
|
||||
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
|
||||
"empty page_token must be omitted")
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.params.include_stats").Exists(),
|
||||
"CLI should not send include_stats query (server returns stats by default)")
|
||||
})
|
||||
|
||||
t.Run("CustomPaginationAndDevEnv", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-table-list",
|
||||
"--app-id", "app_x", "--env", "dev",
|
||||
"--page-size", "50", "--page-token", "cursor-abc",
|
||||
"--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String())
|
||||
assert.Equal(t, "50", gjson.Get(result.Stdout, "api.0.params.page_size").String())
|
||||
assert.Equal(t, "cursor-abc", gjson.Get(result.Stdout, "api.0.params.page_token").String())
|
||||
})
|
||||
|
||||
t.Run("RejectsBlankAppID", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-table-list", "--app-id", " ", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.ExitCode, "blank app-id must fail validation")
|
||||
assert.Contains(t, validateErrorMessage(result), "app-id")
|
||||
})
|
||||
}
|
||||
79
tests/cli_e2e/apps/apps_env_pull_dryrun_test.go
Normal file
79
tests/cli_e2e/apps/apps_env_pull_dryrun_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAppsEnvPullDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
t.Run("DefaultPath", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"apps", "+env-pull",
|
||||
"--app-id", "app_x",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.True(t, gjson.Get(result.Stdout, "project_path").Exists())
|
||||
assert.Contains(t, gjson.Get(result.Stdout, "env_file").String(), ".env.local")
|
||||
assert.False(t, gjson.Get(result.Stdout, "env_keys").Exists())
|
||||
})
|
||||
|
||||
t.Run("CustomProjectPath", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
projectDir := filepath.Join(t.TempDir(), "demo")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"apps", "+env-pull",
|
||||
"--app-id", "app_x",
|
||||
"--project-path", projectDir,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, projectDir, gjson.Get(result.Stdout, "project_path").String())
|
||||
assert.Equal(t, filepath.Join(projectDir, ".env.local"), gjson.Get(result.Stdout, "env_file").String())
|
||||
})
|
||||
|
||||
t.Run("MissingAppID", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"apps", "+env-pull",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
assert.Contains(t, result.Stdout+result.Stderr, `--app-id is required`)
|
||||
})
|
||||
}
|
||||
38
tests/cli_e2e/apps/apps_git_credential_dryrun_test.go
Normal file
38
tests/cli_e2e/apps/apps_git_credential_dryrun_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAppsGitCredentialInitDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"apps", "+git-credential-init",
|
||||
"--app-id", "app_xxx",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_xxx/git_info", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "api.0.params.app_id").String())
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists())
|
||||
}
|
||||
122
tests/cli_e2e/apps/apps_git_credential_local_test.go
Normal file
122
tests/cli_e2e/apps/apps_git_credential_local_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAppsGitCredentialListLocalE2E(t *testing.T) {
|
||||
env := setupAppsGitCredentialLocalEnv(t)
|
||||
seedAppsGitCredentialMetadata(t, env.configDir, "app_a", "https://example.com/git/u/a.git", "pat-ref-a")
|
||||
seedAppsGitCredentialMetadata(t, env.configDir, "app_b", "https://example.com/git/u/b.git", "pat-ref-b")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+git-credential-list"},
|
||||
DefaultAs: "user",
|
||||
Env: env.vars,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "data.count").Int())
|
||||
credentials := map[string]gjson.Result{}
|
||||
for _, credential := range gjson.Get(result.Stdout, "data.credentials").Array() {
|
||||
credentials[credential.Get("app_id").String()] = credential
|
||||
}
|
||||
require.Contains(t, credentials, "app_a")
|
||||
require.Contains(t, credentials, "app_b")
|
||||
assert.Equal(t, "https://example.com/git/u/a.git", credentials["app_a"].Get("repository_url").String())
|
||||
assert.Equal(t, "missing_secret", credentials["app_a"].Get("status").String())
|
||||
assert.Equal(t, "https://example.com/git/u/b.git", credentials["app_b"].Get("repository_url").String())
|
||||
assert.Equal(t, "missing_secret", credentials["app_b"].Get("status").String())
|
||||
assert.False(t, credentials["app_a"].Get("expires_at").Exists())
|
||||
assert.False(t, credentials["app_a"].Get("expired").Exists())
|
||||
}
|
||||
|
||||
func TestAppsGitCredentialRemoveLocalE2E(t *testing.T) {
|
||||
env := setupAppsGitCredentialLocalEnv(t)
|
||||
metadataPath := seedAppsGitCredentialMetadata(t, env.configDir, "app_xxx", "https://example.com/git/u/app.git", "pat-ref-remove")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+git-credential-remove", "--app-id", "app_xxx"},
|
||||
DefaultAs: "user",
|
||||
Env: env.vars,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "data.app_id").String())
|
||||
assert.True(t, gjson.Get(result.Stdout, "data.removed").Bool())
|
||||
assert.NoFileExists(t, metadataPath)
|
||||
}
|
||||
|
||||
type appsGitCredentialLocalEnv struct {
|
||||
configDir string
|
||||
vars map[string]string
|
||||
}
|
||||
|
||||
func setupAppsGitCredentialLocalEnv(t *testing.T) appsGitCredentialLocalEnv {
|
||||
t.Helper()
|
||||
configDir := t.TempDir()
|
||||
homeDir := t.TempDir()
|
||||
gitConfig := filepath.Join(t.TempDir(), ".gitconfig")
|
||||
return appsGitCredentialLocalEnv{
|
||||
configDir: configDir,
|
||||
vars: map[string]string{
|
||||
"LARKSUITE_CLI_CONFIG_DIR": configDir,
|
||||
"LARKSUITE_CLI_APP_ID": "apps_local_test",
|
||||
"LARKSUITE_CLI_APP_SECRET": "apps_local_secret",
|
||||
"LARKSUITE_CLI_BRAND": "feishu",
|
||||
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1",
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1",
|
||||
"LARKSUITE_CLI_DATA_DIR": filepath.Join(homeDir, ".local", "share"),
|
||||
"HOME": homeDir,
|
||||
"GIT_CONFIG_GLOBAL": gitConfig,
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func seedAppsGitCredentialMetadata(t *testing.T, configDir, appID, gitHTTPURL, patRef string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(configDir, "spark", url.PathEscape(appID))
|
||||
require.NoError(t, os.MkdirAll(dir, 0700))
|
||||
path := filepath.Join(dir, "git.json")
|
||||
payload := map[string]any{
|
||||
"version": 1,
|
||||
"app_id": appID,
|
||||
"git_http_url": gitHTTPURL,
|
||||
"profile": "default",
|
||||
"profile_app_id": "apps_local_test",
|
||||
"user_open_id": "ou_local_test",
|
||||
"username": "x-access-token",
|
||||
"pat_ref": patRef,
|
||||
"status": "confirmed",
|
||||
"expires_at": time.Now().Add(24 * time.Hour).Unix(),
|
||||
"updated_at": time.Now().Unix(),
|
||||
}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, append(data, '\n'), 0600))
|
||||
return path
|
||||
}
|
||||
@@ -65,6 +65,63 @@ func TestAppsListDryRun(t *testing.T) {
|
||||
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
|
||||
})
|
||||
|
||||
t.Run("WithKeywordOwnershipAppType", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+list",
|
||||
"--keyword", "survey", "--ownership", "mine", "--app-type", "html",
|
||||
"--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
assert.Equal(t, "survey", gjson.Get(result.Stdout, "api.0.params.keyword").String())
|
||||
assert.Equal(t, "mine", gjson.Get(result.Stdout, "api.0.params.ownership").String())
|
||||
assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.params.app_type").String())
|
||||
})
|
||||
|
||||
t.Run("OmitsEmptyFilters", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+list", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
for _, p := range []string{"keyword", "ownership", "app_type"} {
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.params."+p).Exists(),
|
||||
"empty %s must be omitted", p)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RejectsInvalidOwnership", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+list", "--ownership", "bogus", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.ExitCode, "invalid --ownership enum must be rejected")
|
||||
})
|
||||
|
||||
t.Run("RejectsLegacyUppercaseAppType", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+list", "--app-type", "HTML", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.ExitCode, "legacy uppercase --app-type must be rejected")
|
||||
})
|
||||
|
||||
t.Run("NegativePageSizePassesThrough", func(t *testing.T) {
|
||||
// By design CLI does not bound page_size; server validates. Test pins that
|
||||
// invariant so a well-meaning client-side check doesn't sneak in.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user