Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions cmd/root_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -536,8 +536,11 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
})
}

// TestSetupNotices_ColdStart_NoNotice verifies that missing state
// produces no skills key in the composed notice.
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
// produces no skills key in the composed notice. Users who installed
// skills via `npx skills add` (no stamp) must not see the misleading
// "not installed" notice — only `lark-cli update` users opt into the
// drift tracker.
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
Expand Down Expand Up @@ -568,13 +571,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
}
}

// TestSetupNotices_InSync verifies that matching state produces no
// TestSetupNotices_InSync verifies that a matching stamp produces no
// skills key in the composed notice.
func TestSetupNotices_InSync(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}

Expand All @@ -601,13 +604,13 @@ func TestSetupNotices_InSync(t *testing.T) {
}
}

// TestSetupNotices_Drift verifies mismatching state produces the
// TestSetupNotices_Drift verifies a mismatching stamp produces the
// drift message with both current and target populated.
func TestSetupNotices_Drift(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}

Expand Down Expand Up @@ -656,7 +659,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}

Expand Down
134 changes: 64 additions & 70 deletions cmd/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,11 @@ var (
currentVersion = func() string { return build.Version }
currentOS = runtime.GOOS
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) }
)

func isWindows() bool { return currentOS == osWindows }

// normalizeVersion canonicalizes a version string for state comparison.
// normalizeVersion canonicalizes a version string for stamp comparison.
// Strips a leading "v" so versions written from Makefile (git describe →
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
func normalizeVersion(s string) string {
Expand Down Expand Up @@ -122,9 +121,7 @@ func updateRun(opts *UpdateOptions) error {
cur := currentVersion()
updater := newUpdater()

if !opts.Check {
updater.CleanupStaleFiles()
}
updater.CleanupStaleFiles()
output.PendingNotice = nil

// 1. Fetch latest version
Expand All @@ -140,9 +137,13 @@ func updateRun(opts *UpdateOptions) error {

// 3. Compare versions
if !opts.Force && !update.IsNewer(latest, cur) {
var skillsResult *skillscheck.SyncResult
// Run skills sync before returning — covers the case where the
// binary is already current but skills were never synced.
// Stamp dedup makes this a no-op if skills are already in sync.
// Skip side-effects under --check (pure report path per spec §3.6).
var skillsResult *selfupdate.NpmResult
if !opts.Check {
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
}
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
Expand Down Expand Up @@ -184,7 +185,16 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
applySkillsStatus(out, cur)
// skills_status: pure report, no side effect, no stamp write.
// ReadStamp errors are silently swallowed — if we can't read the
// stamp we just omit the block rather than fail the --check.
if stamp, err := skillscheck.ReadStamp(); err == nil {
out["skills_status"] = map[string]interface{}{
"current": stamp,
"target": cur,
"in_sync": stamp == cur,
}
}
output.PrintJson(io.Out, out)
return nil
}
Expand All @@ -200,7 +210,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
}

func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)

reason := detect.ManualReason()
if opts.JSON {
Expand Down Expand Up @@ -278,7 +288,10 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
return output.ErrBare(output.ExitAPI)
}

skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
// Skills update (best-effort) — uses runSkillsAndStamp so the
// stamp gets persisted on success and dedup applies if a previous
// run already stamped this version.
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)

if opts.JSON {
result := map[string]interface{}{
Expand Down Expand Up @@ -315,37 +328,52 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}

func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
// stamp on success. Skips the npx invocation when the stamp already
// matches stampVersion (unless force is true). The stamp write failure
// emits a warning to io.ErrOut but does NOT fail the update command —
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
// dedup; otherwise returns the underlying *NpmResult with Err semantics
// from RunSkillsUpdate.
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
if !force {
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
return nil
}
}
result := syncSkills(skillscheck.SyncOptions{
Version: stateVersion,
Force: force,
Runner: updater,
})
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
r := updater.RunSkillsUpdate()
if r.Err == nil {
if err := skillscheck.WriteStamp(stampVersion); err != nil {
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
}
}
return result
return r
}

// reportAlreadyUpToDate emits the JSON / pretty output for the
// already-up-to-date branch, including any skills_action / skills_warning
// fields derived from skillsResult. When check is true, this is the pure
// report path (spec §3.6): no side-effects, JSON envelope uses
// skills_status (spec §4.2) instead of skills_action.
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error {
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
if opts.JSON {
out := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
"latest_version": latest, "action": "already_up_to_date",
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
}
if check {
applySkillsStatus(out, cur)
// Pure report — read stamp directly, emit skills_status block.
// ReadStamp errors are silently swallowed — if we can't read
// the stamp we just omit the block rather than fail the --check.
if stamp, err := skillscheck.ReadStamp(); err == nil {
out["skills_status"] = map[string]interface{}{
"current": stamp,
"target": cur,
"in_sync": stamp == cur,
}
}
} else {
applySkillsResult(out, skillsResult)
}
Expand All @@ -359,70 +387,36 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
return nil
}

func applySkillsStatus(env map[string]interface{}, target string) {
state, readable, err := skillscheck.ReadState()
if err != nil || !readable || state.Version == "" {
return
}
status := map[string]interface{}{
"current": state.Version,
"target": target,
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target),
}
if len(state.OfficialSkills) > 0 {
status["official"] = len(state.OfficialSkills)
}
if len(state.UpdatedSkills) > 0 {
status["updated"] = len(state.UpdatedSkills)
}
if len(state.SkippedDeletedSkills) > 0 {
status["skipped_deleted"] = state.SkippedDeletedSkills
}
env["skills_status"] = status
}

func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
// applySkillsResult mutates the JSON envelope to include skills_action
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
switch {
case r == nil:
env["skills_action"] = "in_sync"
case r.Err != nil:
env["skills_action"] = "failed"
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
env["skills_summary"] = skillsSummary(r)
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
}
default:
env["skills_action"] = "synced"
env["skills_summary"] = skillsSummary(r)
}
}

func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
summary := map[string]interface{}{
"official": len(r.Official),
"updated": len(r.Updated),
"added": len(r.Added),
"skipped_deleted": len(r.SkippedDeleted),
}
if len(r.Failed) > 0 {
summary["failed"] = r.Failed
}
return summary
}

func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
// emitSkillsTextHints prints human-readable feedback about the skills
// sync result for non-JSON output.
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
switch {
case r == nil:
// dedup hit — silent (already up to date)
case r.Err != nil:
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
if len(r.Failed) > 0 {
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
}
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
case r.Force:
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
default:
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
if len(r.SkippedDeleted) > 0 {
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
}
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
}
}
Loading
Loading