mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
* 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>
612 lines
20 KiB
Go
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
|
|
}
|