Files
larksuite-cli/shortcuts/apps/git_credential.go
anngo-nk 3c2cc273f7 feat(apps): design_html support, creative-design skill, unified TOS publish (#1901)
* feat(apps): add design_html app type support and credential author identity

- Add design_html to appTypePolicies (same as modern_html: skip install/env-pull/skills-sync)
- Route +html-publish via policy (useTOSPublish) instead of hardcoded type check
- Parse commit_author_name/commit_author_email from +git-credential-init response
- Use server-provided author identity for repo-local git config, fallback to defaults
- Support meta_token as identifier in +get command
- Use envvars.AgentName() for source_agent in +create (reads LARKSUITE_CLI_AGENT_NAME)
- Add creative HTML guide reference skeleton and SKILL.md routing entry
- Update git-credential skill docs with new output fields

* fix(apps): unify html-publish to TOS path, add html to init skip policy

- Remove useTOSPublish policy field, html-publish always uses TOS upload
- Add html type to appTypePolicies (skip install/env-pull/skills-sync)
- Remove design_html from policies (not yet in use)
- Fix git credential dry-run test for new local_effects entry

* feat(apps): validate --app-id format to reject meta_token with resolution hint

* feat(apps): integrate creative-design skill and update skill docs

- Add creative-design skill under lark-apps/ (same level as references/)
- Update SKILL.md description with creative design trigger keywords
- Add creative design routing in development path selection table
- Add --path relative path guidance in html-publish reference
- Remove old creative-html-guide skeleton (replaced by creative-design)

* feat(apps): skip app sync for html/modern_html in +init

Add skipAppSync policy field; html and modern_html skip npx app sync
on non-empty repo path since static HTML sites don't need it.

* fix(apps): merge creative-design into html routing and add intent entry

- Merge static HTML and creative-design into one path selection row
- Add creative-design intent routing entry before html-publish

* docs(apps): add html local dev flow, unify publish link source

- Add html端到端 flow in local-dev.md (create → init → dev → release-create)
- Unify publish link source: html and full_stack both use +release-get
- Update SKILL.md routing and publish护栏 accordingly

* fix(apps): update html-publish dry-run and skill docs for TOS flow

- DryRun shows actual 3-step TOS flow (pre_release → TOS PUT → release-create)
- Skill docs: output is release_id, use +release-get to poll for online_url
- Remove references to legacy multipart upload and data.url

* TEMP: pin miaoda-cli alpha and add BOE header for testing

- Pin miaoda-cli to 0.1.24-alpha.fb2cf0a (revert to @latest before merge)
- Add x-tt-env=boe_aily_lark_cli header globally (remove before merge)
- html app-type uses --template design-html instead of --app-type (remove before merge)

* docs(apps): add creative mode link format and meta_token recognition

- Add creative mode (html) link format `https://{tenant}/page/{meta_token}` in publish护栏
- Note dev and publish URLs are the same for creative mode, unlike full_stack
- Add meta_token to app_id resolution with full link format in app_id获取

* docs(apps): route html apps through local-dev git pipeline by default

- Select dev path: html apps now default to local-dev pipeline instead of skipping local/cloud axis
- Intent routing: creative-design publishes via local-dev flow instead of +html-publish
- Remove +html-publish fallback from local-dev "when not to use" section

* docs(apps): generalize skill references to cover both html and full_stack

Remove full_stack-only wording from init, create, list, env-pull, and
release-create references since html apps now share the same local dev
and release flow.

* feat(apps): add meta_token to +get pretty output and dry-run description

* docs(apps): unify html as creative mode, fix routing and local-dev flow

- Remove "HTML" as separate dev path; html and full_stack both go through local-dev
- Intent routing: read local-dev before creative-design to establish git pipeline first
- Mark +html-publish as legacy, redirect to local-dev for creative mode
- Split html local-dev into 3 scenarios: first-time, iteration, pre-generated files
- git add . instead of selective add to capture all creative-design output files

* docs(apps): remove dev link from html-publish output, only return release-get online_url

* fix: add license header to deck-stage.js

* docs(apps): clarify dev link only for full_stack, creative mode shares dev/pub URL

* docs(apps): remove +html-publish from intent routing, description, and guardrails

All HTML apps now go through local-dev pipeline. +html-publish is deprecated.

* docs(apps): remove html-publish references from create/release-create/cloud-dev pages

html-publish is no longer the recommended path for HTML apps; all html
and full_stack apps now follow the same local-dev + release-create flow.

* fix(apps): address PR review feedback

- html-publish dry-run: register all 3 API calls (GET pre_release, PUT TOS, POST release-create) instead of hiding steps in metadata
- validateRealAppID: remove cli_ prefix check (not a valid app_id prefix)
- E2E: update git-credential dry-run to expect 4 local_effects
- E2E: update html-publish dry-run to expect GET pre_release

* fix(apps): address PR review — remove legacy multipart dead code, fix docs

- Delete html_publish_client.go and html_publish_client_test.go (legacy multipart)
- Remove runHTMLPublish, enrichHTMLPublishAPIError, buildHTMLPublishFailureHint
- Migrate tests from runHTMLPublish to prepareHTMLPublishTarball (same coverage)
- Remove cli_ prefix from validateRealAppID (not a valid app_id prefix)
- Fix html-publish.md error wording to match actual message
- Register all 3 TOS API calls in html-publish dry-run
- Update E2E tests for new dry-run contract

* fix(apps): correctly merge SKILL.md with main (role mgmt, auth wording, source boundary)

Rebuild SKILL.md from our branch version, then merge in main's additions:
- description: add HTML静态站点发布, 应用角色与成员管理, 应用角色/角色成员
- 身份与授权: use main's updated wording (no proactive re-login)
- intent routing: add +role-* row, +init refs 平台资源与应用源码边界
- 能力边界 → 平台资源与应用源码边界 (7 rules from main)
- 禁止预授权底线: add role ② and html-publish ③ clauses

* docs(apps): route legacy html-publish only for non-git html apps

* docs(apps): strengthen local-dev routing and git recovery guidance

fix:cherry-pick and resolve conflicts

* fix: gofmt apps_errors.go and apps_errors_test.go

* docs(apps): strengthen git credential recovery and add file-upload guidance

- Generalize git error recovery: any git operation failure triggers
  +git-credential-init refresh, with environment analysis on failure
- Add resource file upload rule: use +file-upload instead of local
  paths, base64 inlining, or git commits; files are app-scoped

* test(apps): strengthen html-publish dry-run assertions for TOS 3-step contract

* fix: 文件资源上传

* docs(apps): update creative-design skill content

* fix: re-add license header to deck-stage.js

* refactor(apps): merge system-prompt.md into SKILL.md for creative-design skill

Consolidate the thin SKILL.md wrapper and the full system-prompt.md
methodology into a single file, eliminating an unnecessary indirection.
Update references in claude.md and codex.md accordingly.

* chore: revert TEMP changes — miaoda-cli back to @latest, remove BOE header

* docs(apps): remove 可见范围 from 发布态护栏

创意模式的可见范围权限走 lark-drive 文档权限体系,而非妙搭应用
权限体系,当前的 +access-scope-set/get 无法正确管理创意模式应用
的可见范围。待文档协作支持妙搭能力后,再通过 lark-drive 域能力
引导修改。

TODO: 等文档协作支持妙搭能力后,在 skill 中加入使用文档域权限
能力修改创意模式可见范围的引导。

* docs(lark-apps): 在平台资源与应用源码边界添加路径规则,引导 agent 使用相对路径

`apps` 命令的 `--path`、`--file`、`--output` 只接受 cwd 下的相对路径,传绝对路径会报错。

* docs(lark-apps): 新增创意模式评论路由和裸 meta_token 识别引导

- 意图路由表新增创意模式应用评论,引导走 lark-drive 文档评论体系
- app_id 获取章节补充裸 meta_token 识别:非链接非 app_ 开头时尝试用 +get 解析

* refactor(apps): flatten creative-design built-in-skills into references

- Delete built-in-skills/ directory (9 nested sub-skill folders)
- Move media skill content to references/ as flat .md files
- Add assets/index.html React+Babel starter template
- Integrate publishing flow into creative-design SKILL.md
- Update harness reference docs (aily/claude/codex.md)
- Simplify lark-apps SKILL.md routing to point directly to creative-design
- Remove creative-design standalone .git directory

* refactor(apps): rename creative-design/SKILL.md to creative-design.md

Avoid being mistaken as an independent skill entry point.
Update all internal references (lark-apps routing table + 10 reference files).

* fix(apps): fail closed when queryAppType fails instead of falling back to full_stack

queryAppType now returns an error instead of silently returning "".
+init aborts if the app type cannot be determined, preventing wrong
scaffold type from being committed and pushed to the repository.

---------

Co-authored-by: zhangli <zhangli.268@bytedance.com>
2026-07-20 20:15:43 +08:00

612 lines
20 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"sort"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/google/uuid"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/keychain"
"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"
const gitCredentialHelperReportedShortcut = appsService + ":+git-credential-helper"
// 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 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 an app repository",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +git-credential-init --app-id <app_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil {
return appsValidationParamError("--app-id", "%v", err).WithCause(err)
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(gitCredentialIssuePath).
Desc("Issue an app Git repository PAT").
Set("mode", "api-plus-local-setup").
Set("action", "initialize_local_git_credential").
Set("app_id", appID).
Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)).
Set("local_effects", []string{
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
"return commit_author_name and commit_author_email for repo-local git identity",
}).
Params(gitCredentialIssueParams(appID))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, runtimeIssuer{rctx: rctx})
result, err := manager.Init(ctx, profileFromConfig(rctx.Config), appID)
if err != nil {
return gitCredentialLocalError("Initialize local app Git credential", err)
}
payload := map[string]interface{}{
"app_id": result.AppID,
"repository_url": result.GitHTTPURL,
"status": initStatus(result),
}
if result.CommitAuthorName != "" {
payload["commit_author_name"] = result.CommitAuthorName
}
if result.CommitAuthorEmail != "" {
payload["commit_author_email"] = result.CommitAuthorEmail
}
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 an app repository",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +git-credential-remove --app-id <app_id>",
},
Scopes: []string{},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil {
return appsValidationParamError("--app-id", "%v", err).WithCause(err)
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
Desc("Preview local Git credential cleanup (no API call; would clean up local-only state).").
Set("mode", "local-cleanup-only").
Set("action", "remove_local_git_credential").
Set("app_id", appID).
Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)).
Set("effects", []string{
"read app-scoped git credential metadata",
"remove the saved PAT from the local system credential store",
"remove the app-scoped Git helper from global git config when present",
"delete the local metadata record after cleanup succeeds",
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, nil)
result, err := manager.Remove(ctx, profileFromConfig(rctx.Config), appID)
if err != nil {
return gitCredentialLocalError("Remove local app Git credential", err)
}
payload := map[string]interface{}{
"app_id": result.AppID,
"removed": result.Removed,
}
if result.ConfigWarning != "" {
payload["git_config_warning"] = result.ConfigWarning
}
rctx.OutFormat(payload, nil, func(w io.Writer) {
if !result.Removed {
fmt.Fprintln(w, "No local Git credential found")
return
}
fmt.Fprintln(w, "Git credential removed")
fmt.Fprintln(w)
fmt.Fprintf(w, "App ID: %s\n", result.AppID)
if len(result.Records) > 0 {
fmt.Fprintf(w, "Repository URL: %s\n", result.Records[0].GitHTTPURL)
}
fmt.Fprintln(w, "Status: removed")
if result.ConfigWarning != "" {
fmt.Fprintln(w)
fmt.Fprintln(w, "Git config cleanup warning")
fmt.Fprintf(w, "Reason: %s\n", result.ConfigWarning)
}
})
return nil
},
}
var AppsGitCredentialList = common.Shortcut{
Service: appsService,
Command: "+git-credential-list",
Description: "List local Git credentials for app repositories",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +git-credential-list",
},
Scopes: []string{},
AuthTypes: []string{"user"},
HasFormat: true,
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Preview local Git credential listing (no API call, read-only local state).").
Set("mode", "local-read-only").
Set("action", "list_local_git_credentials").
Set("storage_root", filepath.Join(core.GetConfigDir(), storageRoot)).
Set("reads", []string{
"scan app-scoped git credential metadata under the CLI config directory",
"derive per-app repository URLs and local credential status from local metadata",
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now)
if err != nil {
return gitCredentialLocalError("List local app Git credentials", err)
}
payload := map[string]interface{}{
"count": len(records),
"credentials": gitCredentialListPayload(records),
}
rctx.OutFormat(payload, nil, func(w io.Writer) {
if len(records) == 0 {
fmt.Fprintln(w, "No Git credentials initialized")
fmt.Fprintln(w)
fmt.Fprintln(w, "Next step: lark-cli apps +git-credential-init --app-id <app_id>")
return
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "App ID\tRepository URL\tStatus")
for _, record := range records {
fmt.Fprintf(tw, "%s\t%s\t%s\n", record.AppID, record.GitHTTPURL, gitCredentialDisplayStatus(record.Status))
}
_ = tw.Flush()
fmt.Fprintln(w)
fmt.Fprintln(w, "Profile switches do not remove old URL-scoped Git helpers automatically.")
fmt.Fprintln(w, "Cleanup: lark-cli apps +git-credential-remove --app-id <app_id>")
})
return nil
},
}
// 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 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", "", "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, i.rctx.APIClassifyContext())
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, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").
WithHint("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),
}
ctx = contextWithGitCredentialHelperShortcut(ctx)
var opts []larkcore.RequestOptionFunc
if optFn := cmdutil.ShortcutHeaderOpts(ctx); optFn != nil {
opts = append(opts, optFn)
}
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser, opts...)
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
Brand: string(cfg.Brand),
AppID: cfg.AppID,
Identity: string(core.AsUser),
})
if err != nil {
return nil, err
}
return issuedFromData(appID, data)
}
func contextWithGitCredentialHelperShortcut(ctx context.Context) context.Context {
if _, ok := cmdutil.ShortcutNameFromContext(ctx); ok {
return ctx
}
return cmdutil.ContextWithShortcut(ctx, gitCredentialHelperReportedShortcut, uuid.New().String())
}
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
}
// Typed errors pass through unchanged; everything the apps domain and the
// shared runtime produce is typed, so there is no legacy envelope to forward.
if _, ok := errs.UnwrapTypedError(err); ok {
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"),
CommitAuthorName: firstString(source, "commit_author_name"),
CommitAuthorEmail: firstString(source, "commit_author_email"),
}
if issued.AppID == "" {
issued.AppID = appID
}
if issued.GitHTTPURL == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing gitURL")
}
if issued.PAT == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing token")
}
return issued, nil
}
// parseIssueCredentialData turns the git-credential issue response into the
// credential data map. A standard Lark envelope (top-level "code") and any
// HTTP error status route through the shared response classifier, so generic
// codes (missing scope, app not authorized) and 5xx statuses keep their
// canonical category/subtype/retryable classification. The endpoint's
// non-standard success shapes — direct git info or a BaseResp wrapper — are
// handled locally.
func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) {
if err != nil {
return nil, redactGitCredentialIssueError(client.WrapDoAPIError(err))
}
detail := logIDDetail(resp)
if resp == nil || len(resp.RawBody) == 0 {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"Issue app Git credential: empty response body")
}
var result map[string]any
jsonErr := json.Unmarshal(resp.RawBody, &result)
_, hasCode := result["code"]
if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest {
data, cerr := common.ClassifyAPIResponseWith(resp, cc)
if cerr != nil {
return nil, redactGitCredentialIssueError(withAppsHint(cerr, gitCredentialIssueHint))
}
if data != nil {
result = data
}
// data == nil: a code==0 envelope whose fields sit beside "code" instead
// of under "data" — keep the locally-unmarshalled top-level object.
} 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"
}
message = gitcred.RedactCredentialText(message)
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue app Git credential: %s", message).WithCode(int(code))
if logID != "" {
baseErr = baseErr.WithLogID(logID)
}
return baseErr
}
return nil
}
func redactGitCredentialIssueError(err error) error {
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok {
p.Message = gitcred.RedactCredentialText(p.Message)
p.Hint = gitcred.RedactCredentialText(p.Hint)
}
return err
}
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 json.Number:
n, _ := v.Int64()
return n
case string:
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
return n
}
}
return 0
}