feat: skill 检查更新

This commit is contained in:
tiehongji
2026-07-03 16:47:49 +08:00
parent 1a7c548880
commit e101a31398
10 changed files with 636 additions and 12 deletions
+12
View File
@@ -0,0 +1,12 @@
package commands
import "testing"
func TestRootCommandDoesNotExposeSkillsCommand(t *testing.T) {
cmd := newRootCmd()
for _, child := range cmd.Commands() {
if child.Name() == "skills" {
t.Fatal("root command exposes deprecated skills command")
}
}
}
+44 -6
View File
@@ -9,6 +9,8 @@ import (
"time"
buildinfo "mediakit-cli/internal/build"
cliconfig "mediakit-cli/internal/config"
"mediakit-cli/internal/skillstate"
"mediakit-cli/internal/updatecheck"
"github.com/spf13/cobra"
@@ -54,6 +56,7 @@ func newUpdateCmd() *cobra.Command {
payload["upgrade_command"] = "mediakit-cli update"
if checkOnly {
payload["action"] = "check"
applySkillsStatus(payload, r.Current)
if !asJSON {
return writeUpdateCheckText(cmd, r)
}
@@ -78,11 +81,8 @@ func newUpdateCmd() *cobra.Command {
payload["action"] = "noop"
}
shouldInstallSkills := !r.HasUpdate && force
shouldInstallSkills := !r.HasUpdate && shouldInstallSkillsForVersion(r.Current, force)
if shouldInstallSkills {
if !asJSON {
fmt.Fprintln(cmd.OutOrStdout(), "\nInstalling skills from the current npm package ...")
}
if err := runSkillsInstall(cmd); err != nil {
payload["skills_action"] = "failed"
payload["skills_error"] = err.Error()
@@ -132,7 +132,8 @@ func writeUpdateText(cmd *cobra.Command, r *updatecheck.Result, skillsInstalled
fmt.Fprintf(cmd.OutOrStdout(), "mediakit-cli %s is already up to date\n", r.Current)
}
if skillsInstalled {
_, err := fmt.Fprintln(cmd.OutOrStdout(), "✓ Skills installed from the current npm package")
fmt.Fprintln(cmd.OutOrStdout(), "\nUpdating skills ...")
_, err := fmt.Fprintln(cmd.OutOrStdout(), "✓ Skills updated")
return err
}
return nil
@@ -147,12 +148,49 @@ func runNpmInstallLatest(cmd *cobra.Command) error {
}
func runSkillsInstallFromPackage(cmd *cobra.Command) error {
c := exec.Command("npx", "-y", updatecheck.PackageName, "install", "--skills-only", "-y")
c := exec.Command("npx", "-y", currentPackageSpec(), "install", "--skills-only", "-y")
c.Stdout = os.Stderr
c.Stderr = os.Stderr
return c.Run()
}
func currentPackageSpec() string {
version := normalizeVersion(buildinfo.Version)
if version == "" {
return updatecheck.PackageName
}
return fmt.Sprintf("%s@%s", updatecheck.PackageName, version)
}
func shouldInstallSkillsForVersion(version string, force bool) bool {
if force {
return true
}
home, err := cliconfig.ResolveHomeDir()
if err != nil {
return false
}
return !skillstate.InSync(home, version)
}
func applySkillsStatus(payload map[string]any, target string) {
home, err := cliconfig.ResolveHomeDir()
if err != nil {
return
}
status, err := skillstate.ReadStatus(home, target)
if err != nil || status == nil {
return
}
payload["skills_status"] = map[string]any{
"current": status.Current,
"target": status.Target,
"in_sync": status.InSync,
"missing": status.Missing,
"command": status.Command,
}
}
func normalizeVersion(version string) string {
version = strings.TrimSpace(version)
version = strings.TrimPrefix(version, "v")
+390
View File
@@ -0,0 +1,390 @@
package commands
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
buildinfo "mediakit-cli/internal/build"
"mediakit-cli/internal/skillstate"
"mediakit-cli/internal/updatecheck"
"github.com/spf13/cobra"
)
func TestUpdateCheckDoesNotInstallOrSyncSkills(t *testing.T) {
restore := stubUpdateDeps(t, &updatecheck.Result{Current: "0.1.7", Latest: "0.1.8", HasUpdate: true})
defer restore()
var npmCalled bool
runNpmInstall = func(*cobra.Command) error {
npmCalled = true
return nil
}
var skillsCalled bool
runSkillsInstall = func(*cobra.Command) error {
skillsCalled = true
return nil
}
cmd := newUpdateCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--check", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if npmCalled {
t.Fatal("npm install called during update --check")
}
if skillsCalled {
t.Fatal("skills install called during update --check")
}
}
func TestUpdateCheckJsonIncludesSkillsStatus(t *testing.T) {
restore := stubUpdateDeps(t, &updatecheck.Result{Current: "0.1.7", Latest: "0.1.7", HasUpdate: false})
defer restore()
home := mustHome(t)
if err := skillstate.Save(home, &skillstate.State{
PackageName: "@volcengine/mediakit-cli",
Version: "0.1.6",
SkillsDir: "/tmp/old-skills",
InstalledAt: time.Date(
2026, 7, 3, 12, 0, 0, 0, time.UTC,
),
}); err != nil {
t.Fatal(err)
}
cmd := newUpdateCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--check", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatal(err)
}
status, ok := payload["skills_status"].(map[string]any)
if !ok {
t.Fatalf("payload missing skills_status: %#v", payload)
}
if status["current"] != "0.1.6" || status["target"] != "0.1.7" || status["in_sync"] != false {
t.Fatalf("skills_status = %#v", status)
}
if status["command"] != "mediakit-cli update --force" {
t.Fatalf("skills_status = %#v", status)
}
}
func TestUpdateCheckDefaultOutputMatchesHumanNotice(t *testing.T) {
restore := stubUpdateDeps(t, &updatecheck.Result{Current: "0.1.7", Latest: "0.1.8", HasUpdate: true})
defer restore()
cmd := newUpdateCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--check"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
got := out.String()
if !strings.Contains(got, "Update available: 0.1.7 → 0.1.8") {
t.Fatalf("output = %q", got)
}
if !strings.Contains(got, "Run `mediakit-cli update` to install.") {
t.Fatalf("output = %q", got)
}
if strings.Contains(got, "{") {
t.Fatalf("default check output should not be JSON: %q", got)
}
}
func TestUpdateDoesNotInstallSkillsWhenAlreadyLatest(t *testing.T) {
restore := stubUpdateDeps(t, &updatecheck.Result{Current: "0.1.7", Latest: "0.1.7", HasUpdate: false})
defer restore()
home := mustHome(t)
if err := skillstate.Save(home, &skillstate.State{
PackageName: "@volcengine/mediakit-cli",
Version: "0.1.7",
}); err != nil {
t.Fatal(err)
}
var skillsCalled bool
runSkillsInstall = func(*cobra.Command) error {
skillsCalled = true
return nil
}
cmd := newUpdateCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if _, ok := payload["skills_action"]; ok {
t.Fatalf("payload = %#v", payload)
}
if skillsCalled {
t.Fatal("skills install called when CLI is already latest")
}
}
func TestUpdateInstallsSkillsWhenAlreadyLatestButSkillsOutOfSync(t *testing.T) {
restore := stubUpdateDeps(t, &updatecheck.Result{Current: "0.1.7", Latest: "0.1.7", HasUpdate: false})
defer restore()
home := mustHome(t)
if err := skillstate.Save(home, &skillstate.State{
PackageName: "@volcengine/mediakit-cli",
Version: "0.1.6",
}); err != nil {
t.Fatal(err)
}
var skillsCalled bool
runSkillsInstall = func(*cobra.Command) error {
skillsCalled = true
return nil
}
cmd := newUpdateCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
got := out.String()
if !strings.Contains(got, "mediakit-cli 0.1.7 is already up to date") {
t.Fatalf("output = %q", got)
}
if !strings.Contains(got, "Updating skills ...") || !strings.Contains(got, "✓ Skills updated") {
t.Fatalf("output = %q", got)
}
if !skillsCalled {
t.Fatal("skills install not called when skills are out of sync")
}
}
func TestUpdateForceInstallsSkillsWhenAlreadyLatest(t *testing.T) {
restore := stubUpdateDeps(t, &updatecheck.Result{Current: "0.1.7", Latest: "0.1.7", HasUpdate: false})
defer restore()
var skillsCalled bool
runSkillsInstall = func(*cobra.Command) error {
skillsCalled = true
return nil
}
cmd := newUpdateCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--force"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
got := out.String()
if !strings.Contains(got, "mediakit-cli 0.1.7 is already up to date") {
t.Fatalf("output = %q", got)
}
if !strings.Contains(got, "Updating skills ...") || !strings.Contains(got, "✓ Skills updated") {
t.Fatalf("output = %q", got)
}
if strings.Contains(got, "{") {
t.Fatalf("default output should not be JSON: %q", got)
}
if !skillsCalled {
t.Fatal("skills install not called under update --force")
}
}
func TestCurrentPackageSpecUsesRunningVersionForSkillsReinstall(t *testing.T) {
oldVersion := buildinfo.Version
buildinfo.Version = "0.1.8-beta.2"
t.Cleanup(func() {
buildinfo.Version = oldVersion
})
if got, want := currentPackageSpec(), "@volcengine/mediakit-cli@0.1.8-beta.2"; got != want {
t.Fatalf("currentPackageSpec() = %q, want %q", got, want)
}
}
func TestUpdateReliesOnNpmPostinstallForSkillsAfterNpmUpdate(t *testing.T) {
restore := stubUpdateDeps(t, &updatecheck.Result{Current: "0.1.7", Latest: "0.1.8", HasUpdate: true})
defer restore()
var calls []string
runNpmInstall = func(*cobra.Command) error {
calls = append(calls, "npm")
return nil
}
runSkillsInstall = func(*cobra.Command) error {
calls = append(calls, "skills")
return nil
}
cmd := newUpdateCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if got, want := strings.Join(calls, ","), "npm"; got != want {
t.Fatalf("calls = %q, want %q", got, want)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if _, ok := payload["skills_action"]; ok {
t.Fatalf("payload = %#v", payload)
}
}
func TestUpdateDefaultOutputSeparatesCliAndSkillsStatus(t *testing.T) {
restore := stubUpdateDeps(t, &updatecheck.Result{Current: "0.1.7", Latest: "0.1.8", HasUpdate: true})
defer restore()
cmd := newUpdateCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{
"Updating mediakit-cli 0.1.7 → 0.1.8 via npm ...",
"✓ Successfully updated mediakit-cli from 0.1.7 to 0.1.8",
"Updating skills ...",
"✓ Skills updated",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "Changelog:") {
t.Fatalf("output should not include changelog:\n%s", got)
}
}
func TestUpdateDoesNotRunSkillsInstallAfterNpmUpdate(t *testing.T) {
restore := stubUpdateDeps(t, &updatecheck.Result{Current: "0.1.7", Latest: "0.1.8", HasUpdate: true})
defer restore()
runSkillsInstall = func(*cobra.Command) error { return errors.New("skills should not run") }
cmd := newUpdateCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
}
func TestInjectNoticeIncludesSkillsNoticeWhenOutOfSync(t *testing.T) {
restore := stubUpdateDeps(t, &updatecheck.Result{Current: "0.1.7", Latest: "0.1.7", HasUpdate: false})
defer restore()
home := mustHome(t)
if err := skillstate.Save(home, &skillstate.State{
PackageName: "@volcengine/mediakit-cli",
Version: "0.1.6",
}); err != nil {
t.Fatal(err)
}
payload := map[string]any{"ok": true}
updatecheck.InjectNotice(payload)
notice, ok := payload["_notice"].(map[string]any)
if !ok {
t.Fatalf("payload missing _notice: %#v", payload)
}
skills, ok := notice["skills"].(map[string]any)
if !ok {
t.Fatalf("notice missing skills: %#v", notice)
}
if skills["current"] != "0.1.6" || skills["target"] != buildinfo.Version {
t.Fatalf("skills notice = %#v", skills)
}
if skills["command"] != "mediakit-cli update --force" {
t.Fatalf("skills notice = %#v", skills)
}
}
func TestUpdateDoesNotSyncWhenNpmInstallFails(t *testing.T) {
restore := stubUpdateDeps(t, &updatecheck.Result{Current: "0.1.7", Latest: "0.1.8", HasUpdate: true})
defer restore()
runNpmInstall = func(*cobra.Command) error { return errors.New("npm failed") }
var skillsCalled bool
runSkillsInstall = func(*cobra.Command) error {
skillsCalled = true
return nil
}
cmd := newUpdateCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
if err := cmd.Execute(); err == nil {
t.Fatal("Execute returned nil error, want npm failure")
}
if skillsCalled {
t.Fatal("skills install called after npm failure")
}
}
func stubUpdateDeps(t *testing.T, result *updatecheck.Result) func() {
t.Helper()
t.Setenv("HOME", t.TempDir())
oldCheck := checkNow
oldNpm := runNpmInstall
oldSkills := runSkillsInstall
checkNow = func() *updatecheck.Result { return result }
runNpmInstall = func(*cobra.Command) error { return nil }
runSkillsInstall = func(*cobra.Command) error { return nil }
return func() {
checkNow = oldCheck
runNpmInstall = oldNpm
runSkillsInstall = oldSkills
}
}
func mustHome(t *testing.T) string {
t.Helper()
home := os.Getenv("HOME")
if home == "" {
t.Fatal("HOME not set")
}
return filepath.Clean(home)
}
+94
View File
@@ -0,0 +1,94 @@
package skillstate
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
cliconfig "mediakit-cli/internal/config"
)
const (
FileName = "skills-state.json"
PackageName = "@volcengine/mediakit-cli"
)
type State struct {
PackageName string `json:"package_name"`
Version string `json:"version"`
SkillsDir string `json:"skills_dir,omitempty"`
InstalledAt time.Time `json:"installed_at,omitempty"`
}
type Status struct {
Current string `json:"current,omitempty"`
Target string `json:"target"`
InSync bool `json:"in_sync"`
Command string `json:"command"`
Missing bool `json:"missing,omitempty"`
}
func File(home string) string {
return filepath.Join(cliconfig.ConfigDir(home), FileName)
}
func Load(home string) (*State, error) {
data, err := os.ReadFile(File(home))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
if len(data) == 0 {
return nil, nil
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return nil, err
}
return &state, nil
}
func Save(home string, state *State) error {
if state == nil {
return nil
}
if state.PackageName == "" {
state.PackageName = PackageName
}
return cliconfig.WriteJSONAtomic(File(home), state)
}
func ReadStatus(home, target string) (*Status, error) {
target = normalizeVersion(target)
state, err := Load(home)
if err != nil {
return nil, err
}
status := &Status{
Target: target,
Command: "mediakit-cli update --force",
}
if state == nil || strings.TrimSpace(state.Version) == "" {
status.Missing = true
return status, nil
}
status.Current = normalizeVersion(state.Version)
status.InSync = status.Current == target
return status, nil
}
func InSync(home, target string) bool {
status, err := ReadStatus(home, target)
return err == nil && status != nil && status.InSync
}
func normalizeVersion(version string) string {
version = strings.TrimSpace(version)
version = strings.TrimPrefix(version, "v")
return strings.TrimPrefix(version, "V")
}
+38 -5
View File
@@ -4,6 +4,10 @@ import (
"fmt"
"io"
"os"
"mediakit-cli/internal/build"
cliconfig "mediakit-cli/internal/config"
"mediakit-cli/internal/skillstate"
)
// NoticePayload returns a map suitable to be embedded into the JSON stdout
@@ -27,18 +31,47 @@ func InjectNotice(result map[string]any) {
if result == nil {
return
}
payload := NoticePayload()
if payload == nil {
return
}
notice, _ := result["_notice"].(map[string]any)
if notice == nil {
notice = map[string]any{}
}
notice["update"] = payload
if payload := NoticePayload(); payload != nil {
notice["update"] = payload
}
if payload := SkillsNoticePayload(); payload != nil {
notice["skills"] = payload
}
if len(notice) == 0 {
return
}
result["_notice"] = notice
}
func SkillsNoticePayload() map[string]any {
home, err := cliconfig.ResolveHomeDir()
if err != nil {
return nil
}
status, err := skillstate.ReadStatus(home, build.Version)
if err != nil || status == nil || status.InSync {
return nil
}
return map[string]any{
"current": status.Current,
"target": status.Target,
"command": status.Command,
"message": fmt.Sprintf("MediaKit skills are not synced: current %s, target %s, run: %s",
displayVersion(status.Current), status.Target, status.Command),
}
}
func displayVersion(version string) string {
if version == "" {
return "missing"
}
return version
}
// PrintStderrNag prints an unobtrusive update hint to stderr when an update is
// available and stderr is a TTY (character device).
func PrintStderrNag(w io.Writer) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@volcengine/mediakit-cli",
"version": "0.1.8-beta.1",
"version": "0.1.7",
"description": "MediaKit CLI with multi-platform binary distribution via npm",
"license": "MIT",
"bin": {
+20
View File
@@ -2,6 +2,7 @@
const { spawnSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const pkg = require('../package.json')
@@ -122,9 +123,28 @@ function runSkillsAdd(opts) {
if (result.status !== 0) {
throw new Error(`npx skills add failed with exit code ${result.status}`)
}
writeSkillsState()
log('✓ Skills installed')
}
function writeSkillsState() {
const stateFile = path.join(os.homedir(), '.mediakit', 'skills-state.json')
fs.mkdirSync(path.dirname(stateFile), { recursive: true })
fs.writeFileSync(
stateFile,
`${JSON.stringify(
{
package_name: PACKAGE_NAME,
version: pkg.version,
skills_dir: SKILLS_DIR,
installed_at: new Date().toISOString(),
},
null,
2,
)}\n`,
)
}
async function runInstallWizard(rawArgs) {
const opts = parseArgs(rawArgs)
+9
View File
@@ -47,9 +47,12 @@ process.exit(0)
t.after(() => fs.rmSync(temp, { recursive: true, force: true }))
const oldPath = process.env.PATH
const oldHome = process.env.HOME
process.env.PATH = `${bin}${path.delimiter}${oldPath || ''}`
process.env.HOME = temp
t.after(() => {
process.env.PATH = oldPath
process.env.HOME = oldHome
})
await runInstallWizard(['--skills-only', '--skill', 'byted-mediakit-video', '-y'])
@@ -72,6 +75,12 @@ process.exit(0)
'-y',
])
assert(!calls[0].args.some((arg) => /^https?:\/\//.test(String(arg))))
const state = JSON.parse(
fs.readFileSync(path.join(temp, '.mediakit', 'skills-state.json'), 'utf8'),
)
assert.equal(state.package_name, '@volcengine/mediakit-cli')
assert.equal(state.version, '0.1.7')
assert.equal(state.skills_dir, path.join(__dirname, '..', 'skills'))
})
test('regular install delegates skills installation to npm postinstall', async (t) => {
+19
View File
@@ -213,9 +213,28 @@ function installSkills() {
if (result.status !== 0) {
throw new Error(`npx skills add failed with exit code ${result.status}`);
}
writeSkillsState();
console.log("[mediakit-cli] ✓ Skills installed");
}
function writeSkillsState() {
const stateFile = path.join(os.homedir(), ".mediakit", "skills-state.json");
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(
stateFile,
`${JSON.stringify(
{
package_name: pkg.name || "@volcengine/mediakit-cli",
version: pkg.version,
skills_dir: skillsDir,
installed_at: new Date().toISOString(),
},
null,
2
)}\n`
);
}
async function install(options = {}) {
if (readEnv("MEDIAKIT_CLI_SKIP_DOWNLOAD", "MEDIKIT_CLI_SKIP_DOWNLOAD") === "1") {
console.log("[mediakit-cli] skip download because MEDIAKIT_CLI_SKIP_DOWNLOAD=1");
+9
View File
@@ -39,11 +39,14 @@ process.exit(0)
t.after(() => fs.rmSync(temp, { recursive: true, force: true }))
const oldPath = process.env.PATH
const oldHome = process.env.HOME
const oldSkip = process.env.MEDIAKIT_CLI_SKIP_DOWNLOAD
process.env.PATH = `${bin}${path.delimiter}${oldPath || ''}`
process.env.HOME = temp
delete process.env.MEDIAKIT_CLI_SKIP_DOWNLOAD
t.after(() => {
process.env.PATH = oldPath
process.env.HOME = oldHome
if (oldSkip === undefined) {
delete process.env.MEDIAKIT_CLI_SKIP_DOWNLOAD
} else {
@@ -69,4 +72,10 @@ process.exit(0)
])
assert.match(output, /Installing skills \.\.\./)
assert.match(output, /✓ Skills installed/)
const state = JSON.parse(
fs.readFileSync(path.join(temp, '.mediakit', 'skills-state.json'), 'utf8'),
)
assert.equal(state.package_name, '@volcengine/mediakit-cli')
assert.equal(state.version, '0.1.7')
assert.equal(state.skills_dir, path.join(__dirname, '..', 'skills'))
})