mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
3c2cc273f7
* 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>
561 lines
17 KiB
Go
561 lines
17 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package gitcred
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"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
|
|
}
|
|
|
|
// credentialKeys is the shared list of credential field names to redact; the
|
|
// bare, double-quoted (JSON), and single-quoted forms all reuse it.
|
|
const credentialKeys = `access_token|refresh_token|app_secret|token|pat|password|secret`
|
|
|
|
var (
|
|
credentialURLUserinfoRE = regexp.MustCompile(`(?i)(https?://)[^/\s]+@`)
|
|
// credentialAssignmentRE matches credential key assignments, including JSON
|
|
// quoted forms. Capture group 1 is the key and separator; only the value is
|
|
// replaced with <redacted>. The key is one of three forms — double-quoted,
|
|
// single-quoted, or bare with a word boundary — so concatenated words like
|
|
// mytoken are not matched. Each form wraps the key list in (?:...) so the |
|
|
// alternation does not bind the quote/boundary to only the first and last key.
|
|
credentialAssignmentRE = regexp.MustCompile(
|
|
`(?i)((?:"(?:` + credentialKeys + `)"|'(?:` + credentialKeys + `)'|\b(?:` + credentialKeys + `)\b)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)`,
|
|
)
|
|
credentialBearerRE = regexp.MustCompile(`(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+`)
|
|
credentialPATLikeRE = regexp.MustCompile(`(?i)\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|pat-[A-Za-z0-9._-]+)\b`)
|
|
)
|
|
|
|
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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
|
|
}
|
|
if err := validate.ResourceName(appID, "--app-id"); err != nil {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err)
|
|
}
|
|
if profile.UserOpenID == "" {
|
|
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").WithHint("run `lark-cli auth login --scope \"spark:app:read\"`")
|
|
}
|
|
unlockApp, err := lockApp(appID)
|
|
if err != nil {
|
|
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err)
|
|
}
|
|
defer unlockApp()
|
|
if m.Issuer == nil {
|
|
return nil, errs.NewInternalError(errs.SubtypeUnknown, "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,
|
|
CommitAuthorName: issued.CommitAuthorName,
|
|
CommitAuthorEmail: issued.CommitAuthorEmail,
|
|
}
|
|
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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
|
|
}
|
|
if err := validate.ResourceName(appID, "--app-id"); err != nil {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err)
|
|
}
|
|
unlockApp, err := lockApp(appID)
|
|
if err != nil {
|
|
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(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 {
|
|
writeCredentialError(errOut, "Git credential unavailable", err)
|
|
return nil
|
|
}
|
|
record, pat, ok, err := m.readConfirmed(url, current)
|
|
if err != nil {
|
|
writeCredentialError(errOut, "Git credential unavailable", err)
|
|
return nil
|
|
}
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if m.usable(record, pat) {
|
|
return writeGitCredential(out, record.Username, pat)
|
|
}
|
|
|
|
// Lock ordering convention (see lock.go package comment): always acquire
|
|
// lockApp before lockURL. lockApp is a cross-process file lock with a
|
|
// timeout and possible setup failure; acquiring it first avoids holding an
|
|
// in-process mutex on the failure path, which would risk a deadlock.
|
|
unlockApp, err := lockApp(record.AppID)
|
|
if err != nil {
|
|
// lockApp may already return a typed error, for example when creating
|
|
// the lock directory fails. Preserve those classifications and only wrap
|
|
// raw lockfile errors to add app context.
|
|
if _, ok := errs.ProblemOf(err); !ok {
|
|
err = errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err)
|
|
}
|
|
writeCredentialError(errOut, "Git credential refresh failed", err)
|
|
return nil
|
|
}
|
|
defer unlockApp()
|
|
unlockURL := lockURL(url)
|
|
defer unlockURL()
|
|
|
|
record, pat, ok, err = m.readConfirmed(url, current)
|
|
if err != nil {
|
|
writeCredentialError(errOut, "Git credential unavailable", 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 {
|
|
writeCredentialError(errOut, "Git credential refresh failed", err)
|
|
fmt.Fprintf(errOut, "Next step: lark-cli apps +git-credential-init --app-id %s\n", record.AppID)
|
|
return nil
|
|
}
|
|
issuedURL, urlErr := NormalizeGitHTTPURL(issued.GitHTTPURL)
|
|
if urlErr != nil {
|
|
writeCredentialError(errOut, "Git credential refresh failed", urlErr)
|
|
return nil
|
|
}
|
|
if err := validateIssuedCredential(record.AppID, issuedURL, issued, m.nowUnix()); err != nil {
|
|
writeCredentialError(errOut, "Git credential refresh failed", 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 {
|
|
writeCredentialError(errOut, "Git credential unavailable", 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 {
|
|
writeCredentialError(errOut, "Git credential refresh failed", err)
|
|
return nil
|
|
}
|
|
if err := m.Store.Upsert(record); err != nil {
|
|
_ = m.Secrets.Set(record.PATRef, oldPAT)
|
|
writeCredentialError(errOut, "Git credential refresh failed", err)
|
|
return nil
|
|
}
|
|
return writeGitCredential(out, record.Username, issued.PAT)
|
|
}
|
|
|
|
func writeCredentialError(w io.Writer, prefix string, err error) {
|
|
if w == nil || err == nil {
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "%s: %s\n", prefix, safeCredentialErrorMessage(err))
|
|
}
|
|
|
|
func safeCredentialErrorMessage(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
if p, ok := errs.ProblemOf(err); ok {
|
|
message := RedactCredentialText(p.Message)
|
|
if p.LogID != "" {
|
|
if message != "" {
|
|
message += "; "
|
|
}
|
|
message += "log_id=" + p.LogID
|
|
}
|
|
if p.Hint != "" {
|
|
if message != "" {
|
|
message += "; "
|
|
}
|
|
message += "hint: " + RedactCredentialText(p.Hint)
|
|
}
|
|
if message != "" {
|
|
return message
|
|
}
|
|
if p.Category != "" || p.Subtype != "" {
|
|
return strings.Trim(strings.TrimSpace(string(p.Category)+"/"+string(p.Subtype)), "/")
|
|
}
|
|
}
|
|
return RedactCredentialText(err.Error())
|
|
}
|
|
|
|
// RedactCredentialText masks credential fragments that may appear in free
|
|
// text, covering URL userinfo, Authorization bearer headers, credential
|
|
// assignments including JSON-quoted forms, and PAT-shaped strings. Shared by
|
|
// the gitcred and apps packages so the redaction logic does not fork.
|
|
func RedactCredentialText(text string) string {
|
|
text = credentialURLUserinfoRE.ReplaceAllString(text, "${1}***@")
|
|
text = credentialBearerRE.ReplaceAllString(text, "${1}<redacted>")
|
|
text = credentialAssignmentRE.ReplaceAllString(text, "${1}<redacted>")
|
|
text = credentialPATLikeRE.ReplaceAllString(text, "<redacted>")
|
|
return text
|
|
}
|
|
|
|
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 errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(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, errs.NewValidationError(errs.SubtypeFailedPrecondition, "current login does not match initialized credential").
|
|
WithHint(fmt.Sprintf("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{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "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 errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: empty credential")
|
|
}
|
|
if issued.AppID != "" && issued.AppID != appID {
|
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
|
|
}
|
|
if normalizedURL == "" {
|
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing gitURL")
|
|
}
|
|
if strings.TrimSpace(issued.PAT) == "" {
|
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing token")
|
|
}
|
|
if issued.ExpiresAt <= now {
|
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response expiredTime must be in the future")
|
|
}
|
|
return nil
|
|
}
|