Files
larksuite-cli/shortcuts/apps/git_credential.go
2026-06-30 19:57:04 +08:00

603 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",
}).
Params(gitCredentialIssueParams(appID))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, runtimeIssuer{rctx: rctx})
result, err := manager.Init(ctx, profileFromConfig(rctx.Config), appID)
if err != nil {
return gitCredentialLocalError("Initialize local app Git credential", err)
}
payload := map[string]interface{}{
"app_id": result.AppID,
"repository_url": result.GitHTTPURL,
"status": initStatus(result),
}
if result.ConfigWarning != "" {
payload["git_config_warning"] = result.ConfigWarning
}
rctx.OutFormat(payload, nil, func(w io.Writer) {
title := "Git credential initialized"
if result.Refreshed {
title = "Git credential refreshed"
}
fmt.Fprintln(w, title)
fmt.Fprintln(w)
fmt.Fprintf(w, "App ID: %s\n", result.AppID)
fmt.Fprintf(w, "Status: %s\n", initStatus(result))
fmt.Fprintf(w, "Repository URL: %s\n", result.GitHTTPURL)
if result.ConfigWarning != "" {
fmt.Fprintln(w)
fmt.Fprintln(w, "Git credential saved, but Git helper was not configured")
fmt.Fprintf(w, "Reason: %s\n", result.ConfigWarning)
fmt.Fprintf(w, "Next step: lark-cli apps +git-credential-init --app-id %s\n", result.AppID)
return
}
fmt.Fprintln(w)
fmt.Fprintln(w, "Next step:")
fmt.Fprintf(w, " git clone %s\n", result.GitHTTPURL)
})
return nil
},
}
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"),
}
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
}