mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
fix(webui,plugin): 内核控制按钮晚绑定 + 按钮布局;plugin_start 报错给候选名
1. 内核控制通道晚绑定(真机 503 实测) POST /api/v1/kernel/restart 一直返回 503:webui 由 plugin-host 在装配期 拉起,而 main() 里 SetKernelControl 还没执行 ⇒ 插件 Start 拿到 nil。 依赖图保证不了 main 里的语句顺序,所以改成让插件拿**解析器** (lazyKernelControl),调用那一刻再取真实通道;授权仍在每次调用时 按 kernelControlAllowed/kernelRestartAllowed 复核,不因晚绑定放宽。 2. WebUI 重启/关机按钮布局 原先用 .theme-btn(顶栏图标样式:pill + margin-right + 无文字)混在 侧栏底部的文字按钮里 ⇒ 大小不一、挤在一起、关机看不出是危险操作。 改为与「退出登录」同族的 btn-sm,带图标+文字,关机用 danger 悬停。 3. plugin_start 失败报错 原来只说加载出错,或插件不存在,一个可用名字都不给,模型只能盲猜 (实测把 files 写成 file)。现在给:失败原因分类(目录不存在/没清单/ 自身加载失败)、最接近的名字(Levenshtein ≤2)、已安装清单(最多 12)。 4. scripts/migrate-plugins-to-org.sh:插件迁到 GitHub 组织独立仓的脚本收口 测试:go build ./... 通过;go test ./internal/plugin ./internal/plugins/webui 全绿。
This commit is contained in:
73
internal/plugin/kernel_control_lazy_test.go
Normal file
73
internal/plugin/kernel_control_lazy_test.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// 内核控制通道**晚绑定**的判据。
|
||||||
|
//
|
||||||
|
// 现场(2026-10-26 真机):WebUI 重启按钮点击返回 503。
|
||||||
|
// 日志:POST /api/v1/kernel/restart auth=session status=503
|
||||||
|
//
|
||||||
|
// 根因不是授权,而是**时序**:webui 由 plugin-host 在装配期拉起,
|
||||||
|
// 而 main() 里 SetKernelControl 那一行还没执行 ⇒ 插件 Start 时拿到 nil。
|
||||||
|
// 靠"调整顺序"修不了(依赖图不保证 main 里的语句顺序),
|
||||||
|
// 只能让插件拿**解析器**,调用那一刻再取真实通道。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeCtl struct {
|
||||||
|
restarts int
|
||||||
|
shutdowns int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCtl) RequestKernelRestart(reason string) error {
|
||||||
|
f.restarts++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeCtl) RequestKernelShutdown(reason string) error {
|
||||||
|
f.shutdowns++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 先给通道、后注入 ⇒ 调用时仍能成功(这是真机那条修复的核心)。
|
||||||
|
func TestLazyKernelControlWorksWhenInjectedLater(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
ctl := kernelControlFor("webui", r) // 此时 registry 里还没有通道
|
||||||
|
if ctl == nil {
|
||||||
|
t.Fatal("按授权表 webui 应拿到控制通道(晚绑定)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注入前调用 ⇒ 明确报不可用(不能静默成功)
|
||||||
|
if err := ctl.RequestKernelRestart("before"); err == nil {
|
||||||
|
t.Fatal("未注入时应报错,不能假装成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
fake := &fakeCtl{}
|
||||||
|
r.SetKernelControl(fake)
|
||||||
|
if err := ctl.RequestKernelRestart("after"); err != nil {
|
||||||
|
t.Fatalf("注入后应成功,实际: %v", err)
|
||||||
|
}
|
||||||
|
if fake.restarts != 1 {
|
||||||
|
t.Fatalf("应调用真实通道 1 次,实际 %d", fake.restarts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 晚绑定**不放宽授权**:未获授权的插件仍然拿不到通道。
|
||||||
|
func TestLazyKernelControlRespectsAuthorization(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if ctl := kernelControlFor("some-random-plugin", r); ctl != nil {
|
||||||
|
t.Fatal("未授权插件不应拿到控制通道")
|
||||||
|
}
|
||||||
|
// pluginmgr 只有重启权限:关机必须被拒
|
||||||
|
r.SetKernelControl(&fakeCtl{})
|
||||||
|
pm := kernelControlFor("pluginmgr", r)
|
||||||
|
if pm == nil {
|
||||||
|
t.Fatal("pluginmgr 应有仅重启通道")
|
||||||
|
}
|
||||||
|
if err := pm.RequestKernelShutdown("x"); err == nil {
|
||||||
|
t.Fatal("pluginmgr 不应能关机")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ sdk.KernelControlAPI = lazyKernelControl{}
|
||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@ -349,6 +350,56 @@ func (r *Registry) kernelControlFor(name string) sdk.KernelControlAPI {
|
|||||||
//
|
//
|
||||||
// 拆出来的原因:buildSDK 要在**一次** RLock 里取走整组依赖(见 snapshotDeps),
|
// 拆出来的原因:buildSDK 要在**一次** RLock 里取走整组依赖(见 snapshotDeps),
|
||||||
// 不能再回头单独取锁 —— RWMutex 不可重入,持锁再 RLock 在写者排队时会自锁。
|
// 不能再回头单独取锁 —— RWMutex 不可重入,持锁再 RLock 在写者排队时会自锁。
|
||||||
|
// lazyKernelControl 是**晚绑定**的内核控制通道。
|
||||||
|
//
|
||||||
|
// 它不持有具体实现,只持有 registry:每次调用时按授权表重新解析。
|
||||||
|
// 这样"插件先启动、通道后注入"不会丢能力,而授权仍由
|
||||||
|
// kernelControlAllowed / kernelRestartAllowed 决定(不因晚绑定放宽)。
|
||||||
|
type lazyKernelControl struct {
|
||||||
|
r *Registry
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l lazyKernelControl) resolve() sdk.KernelControlAPI {
|
||||||
|
return kernelControlForCtl(l.r.currentKernelControl(), l.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l lazyKernelControl) RequestKernelRestart(reason string) error {
|
||||||
|
if c := l.resolve(); c != nil {
|
||||||
|
return c.RequestKernelRestart(reason)
|
||||||
|
}
|
||||||
|
return errKernelControlUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l lazyKernelControl) RequestKernelShutdown(reason string) error {
|
||||||
|
if c := l.resolve(); c != nil {
|
||||||
|
return c.RequestKernelShutdown(reason)
|
||||||
|
}
|
||||||
|
return errKernelControlUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// errKernelControlUnavailable 表示通道尚未注入(装配期)或本插件未获授权。
|
||||||
|
var errKernelControlUnavailable = errors.New(
|
||||||
|
"内核控制通道不可用:未注入(装配未完成)或本插件未获授权")
|
||||||
|
|
||||||
|
// currentKernelControl 线程安全地取当前通道。
|
||||||
|
func (r *Registry) currentKernelControl() sdk.KernelControlAPI {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return r.kernelCtl
|
||||||
|
}
|
||||||
|
|
||||||
|
// kernelControlFor 给插件返回**晚绑定**控制通道。
|
||||||
|
//
|
||||||
|
// 返回非 nil 即表示"该插件按授权表**可能**有这个能力"——真正的授权仍在
|
||||||
|
// 每次调用时按 kernelControlForCtl 复核(所以晚绑定不放宽任何权限)。
|
||||||
|
func kernelControlFor(name string, r *Registry) sdk.KernelControlAPI {
|
||||||
|
if !kernelControlAllowed[name] && !kernelRestartAllowed[name] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return lazyKernelControl{r: r, name: name}
|
||||||
|
}
|
||||||
|
|
||||||
func kernelControlForCtl(ctl sdk.KernelControlAPI, name string) sdk.KernelControlAPI {
|
func kernelControlForCtl(ctl sdk.KernelControlAPI, name string) sdk.KernelControlAPI {
|
||||||
if ctl == nil {
|
if ctl == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -710,7 +761,16 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
|||||||
RegOutputUnreg: regOutputUnreg,
|
RegOutputUnreg: regOutputUnreg,
|
||||||
RegInput: regInput,
|
RegInput: regInput,
|
||||||
PluginMgr: r,
|
PluginMgr: r,
|
||||||
KernelControl: kernelControlForCtl(deps.kernelCtl, name),
|
// ★ 晚绑定(2026-10-26 真机 503 实测)。
|
||||||
|
//
|
||||||
|
// 原先把 deps.kernelCtl 的**当时的取值**交给插件,而 webui 由
|
||||||
|
// plugin-host 在装配期就拉起,main() 里 SetKernelControl 那一行
|
||||||
|
// 还没执行 ⇒ 插件拿到 nil ⇒ 按钮点了永远 503
|
||||||
|
// (日志证据:POST /api/v1/kernel/restart status=503)。
|
||||||
|
//
|
||||||
|
// 依赖图保证不了"main 里某一行先于插件启动",所以不能靠调整顺序,
|
||||||
|
// 只能让插件拿到**解析器**:调用那一刻再取真实通道。
|
||||||
|
KernelControl: kernelControlFor(name, r),
|
||||||
|
|
||||||
Status: deps.status,
|
Status: deps.status,
|
||||||
Supervisor: deps.sup,
|
Supervisor: deps.sup,
|
||||||
@ -1761,7 +1821,18 @@ func (r *Registry) StartPlugin(name string) error {
|
|||||||
r.runtimeStopped[name] = true
|
r.runtimeStopped[name] = true
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
return fmt.Errorf("启动插件 %s 失败(本身加载出错,或插件不存在)", name)
|
// ★ 报错要**带上可用名字**:模型最常见的错是拼错(实测把 files 写成 file),
|
||||||
|
// 而原来的"加载出错,或插件不存在"两者都提、却一个可用名字都不给,
|
||||||
|
// 模型只能盲猜下一个名字(连续撞 3 次都不奇怪)。
|
||||||
|
//
|
||||||
|
// 这里列目录里的真实插件名(最多 12 个)并指出最接近的那个。
|
||||||
|
avail := r.installedPluginNames()
|
||||||
|
hint := ""
|
||||||
|
if near := nearestName(name, avail); near != "" {
|
||||||
|
hint = fmt.Sprintf(";你要找的是不是 %q?", near)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("启动插件 %s 失败(%s)%s 已安装: %v",
|
||||||
|
name, r.loadFailureReason(name), hint, avail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// refuseSelfLock 拦住会自锁的关闭/禁用操作(设计 §2.6.7.4)。
|
// refuseSelfLock 拦住会自锁的关闭/禁用操作(设计 §2.6.7.4)。
|
||||||
@ -2045,3 +2116,92 @@ func (r *Registry) readConfig(plgDir string) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// installedPluginNames 返回 plugins 目录下真实存在的插件名(排序,最多 12 个)。
|
||||||
|
func (r *Registry) installedPluginNames() []string {
|
||||||
|
dir := r.pluginDirOrEmpty()
|
||||||
|
if dir == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e.Name())
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
if len(out) > 12 {
|
||||||
|
out = out[:12]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFailureReason 说明"为什么没加载起来":目录不存在 / 没清单 / 加载报错。
|
||||||
|
//
|
||||||
|
// 区分三者是必要的:目录不存在(名字错)与清单损坏(插件坏了)对模型
|
||||||
|
// 意味着完全不同的下一步动作。
|
||||||
|
func (r *Registry) loadFailureReason(name string) string {
|
||||||
|
dir := filepath.Join(r.pluginDirOrEmpty(), name)
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
return "该名字没有对应目录,可能拼错了"
|
||||||
|
}
|
||||||
|
hasManifest := false
|
||||||
|
for _, n := range []string{"plugin.json", "plg.json"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, n)); err == nil {
|
||||||
|
hasManifest = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasManifest {
|
||||||
|
return "目录存在但没有 plugin.json/plg.json 清单"
|
||||||
|
}
|
||||||
|
return "插件自身加载失败,原因见内核日志"
|
||||||
|
}
|
||||||
|
|
||||||
|
// nearestName 在一个候选集合里找与 name 最接近的(差一个字符以内视为接近)。
|
||||||
|
func nearestName(name string, candidates []string) string {
|
||||||
|
best, bestD := "", 3
|
||||||
|
for _, c := range candidates {
|
||||||
|
if d := editDistance(strings.ToLower(name), strings.ToLower(c)); d < bestD {
|
||||||
|
best, bestD = c, d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bestD <= 2 {
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// editDistance 是经典 Levenshtein 距离(只用于给"拼错"提建议,规模极小)。
|
||||||
|
func editDistance(a, b string) int {
|
||||||
|
ra, rb := []rune(a), []rune(b)
|
||||||
|
prev := make([]int, len(rb)+1)
|
||||||
|
for j := range prev {
|
||||||
|
prev[j] = j
|
||||||
|
}
|
||||||
|
for i := 1; i <= len(ra); i++ {
|
||||||
|
cur := make([]int, len(rb)+1)
|
||||||
|
cur[0] = i
|
||||||
|
for j := 1; j <= len(rb); j++ {
|
||||||
|
cost := 1
|
||||||
|
if ra[i-1] == rb[j-1] {
|
||||||
|
cost = 0
|
||||||
|
}
|
||||||
|
m := prev[j] + 1
|
||||||
|
if cur[j-1]+1 < m {
|
||||||
|
m = cur[j-1] + 1
|
||||||
|
}
|
||||||
|
if prev[j-1]+cost < m {
|
||||||
|
m = prev[j-1] + cost
|
||||||
|
}
|
||||||
|
cur[j] = m
|
||||||
|
}
|
||||||
|
prev = cur
|
||||||
|
}
|
||||||
|
return prev[len(rb)]
|
||||||
|
}
|
||||||
|
|||||||
@ -598,6 +598,28 @@
|
|||||||
all var(--dur-micro) var(--ease-out),
|
all var(--dur-micro) var(--ease-out),
|
||||||
transform var(--dur-micro) var(--ease-out);
|
transform var(--dur-micro) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
/* 内核控制按钮组(侧栏底部):与"退出登录"同族,带文字与图标。
|
||||||
|
两枚按钮等宽、同一行、间距一致;关机用红色悬停提示危险操作。 */
|
||||||
|
.nav-kernel {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
.nav-kernel .btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.nav-kernel .nav-danger:hover {
|
||||||
|
color: #e5484d;
|
||||||
|
border-color: rgba(229, 72, 77, 0.5);
|
||||||
|
background: rgba(229, 72, 77, 0.08);
|
||||||
|
}
|
||||||
.theme-btn:hover {
|
.theme-btn:hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border-color: rgba(var(--accent-rgb), 0.5);
|
border-color: rgba(var(--accent-rgb), 0.5);
|
||||||
|
|||||||
@ -142,33 +142,43 @@
|
|||||||
</button>
|
</button>
|
||||||
<!-- 内核控制:重启 / 关机。
|
<!-- 内核控制:重启 / 关机。
|
||||||
放在导航底部而不是页面里,是因为它们作用于**整个服务**,
|
放在导航底部而不是页面里,是因为它们作用于**整个服务**,
|
||||||
不属于任何一个标签页。 -->
|
不属于任何一个标签页。
|
||||||
|
|
||||||
|
★ 2026-10-26:原先是两个只有图标的 .theme-btn(那是**顶栏**的样式:
|
||||||
|
有 margin-right、pill 圆角、无文字),混在侧栏底部的文字按钮里
|
||||||
|
⇒ 大小不一、挤在一起、且用户看不出哪个是关机。
|
||||||
|
现在改成与"退出登录"同族的 btn-sm,带图标 + 文字,
|
||||||
|
关机额外用 danger 样式(红色悬停)区别于重启。 -->
|
||||||
|
<div class="nav-kernel">
|
||||||
<button
|
<button
|
||||||
class="theme-btn"
|
class="btn btn-ghost btn-sm"
|
||||||
id="kernel-restart-btn"
|
id="kernel-restart-btn"
|
||||||
onclick="kernelRestart()"
|
onclick="kernelRestart()"
|
||||||
title="重启内核(服务约断线数秒后自动重连)"
|
title="重启内核(服务断线数秒后自动重连;进行中的对话可能中断)"
|
||||||
aria-label="重启内核"
|
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none"
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
|
aria-hidden="true">
|
||||||
<path d="M21 12a9 9 0 1 1-2.6-6.4" />
|
<path d="M21 12a9 9 0 1 1-2.6-6.4" />
|
||||||
<path d="M21 3v6h-6" />
|
<path d="M21 3v6h-6" />
|
||||||
</svg>
|
</svg>
|
||||||
|
重启
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="theme-btn"
|
class="btn btn-ghost btn-sm nav-danger"
|
||||||
id="kernel-shutdown-btn"
|
id="kernel-shutdown-btn"
|
||||||
onclick="kernelShutdown()"
|
onclick="kernelShutdown()"
|
||||||
title="关闭内核(需人工 systemctl start 才能再启动)"
|
title="关闭内核(不会自动重启,需人工 systemctl start)"
|
||||||
aria-label="关闭内核"
|
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none"
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
|
aria-hidden="true">
|
||||||
<path d="M12 3v9" />
|
<path d="M12 3v9" />
|
||||||
<path d="M6.3 6.3a8 8 0 1 0 11.4 0" />
|
<path d="M6.3 6.3a8 8 0 1 0 11.4 0" />
|
||||||
</svg>
|
</svg>
|
||||||
|
关机
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-sm"
|
class="btn btn-ghost btn-sm"
|
||||||
onclick="logout()"
|
onclick="logout()"
|
||||||
|
|||||||
@ -29,8 +29,14 @@ ONLY=""
|
|||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--dry-run) DRY=1 ;;
|
--dry-run) DRY=1 ;;
|
||||||
--only) shift; ONLY="${1:-}" ;;
|
--only)
|
||||||
*) echo "未知参数: $arg" >&2; exit 2 ;;
|
shift
|
||||||
|
ONLY="${1:-}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "未知参数: $arg" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
shift || true
|
shift || true
|
||||||
done
|
done
|
||||||
@ -61,7 +67,9 @@ api() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
created=0; pushed=0; skipped=0
|
created=0
|
||||||
|
pushed=0
|
||||||
|
skipped=0
|
||||||
|
|
||||||
for dir in "$EXAMPLES"/*/; do
|
for dir in "$EXAMPLES"/*/; do
|
||||||
name="$(basename "$dir")"
|
name="$(basename "$dir")"
|
||||||
@ -92,10 +100,10 @@ for dir in "$EXAMPLES"/*/; do
|
|||||||
-d "{\"name\":\"$name\",\"private\":false,\"description\":\"$desc\",\"has_issues\":true}")
|
-d "{\"name\":\"$name\",\"private\":false,\"description\":\"$desc\",\"has_issues\":true}")
|
||||||
if [ "$code" = "201" ]; then
|
if [ "$code" = "201" ]; then
|
||||||
echo " ✓ 仓库已创建"
|
echo " ✓ 仓库已创建"
|
||||||
created=$((created+1))
|
created=$((created + 1))
|
||||||
elif [ "$code" = "422" ]; then
|
elif [ "$code" = "422" ]; then
|
||||||
echo " · 仓库已存在(跳过创建)"
|
echo " · 仓库已存在(跳过创建)"
|
||||||
skipped=$((skipped+1))
|
skipped=$((skipped + 1))
|
||||||
else
|
else
|
||||||
echo " ✗ 创建失败 HTTP=$code: $(head -c 200 /tmp/ghresp)"
|
echo " ✗ 创建失败 HTTP=$code: $(head -c 200 /tmp/ghresp)"
|
||||||
continue
|
continue
|
||||||
@ -108,10 +116,10 @@ for dir in "$EXAMPLES"/*/; do
|
|||||||
# --exclude 用**不带前导斜杠**的形态,匹配任意层级同名目录。
|
# --exclude 用**不带前导斜杠**的形态,匹配任意层级同名目录。
|
||||||
work=$(mktemp -d)
|
work=$(mktemp -d)
|
||||||
mkdir -p "$work/$name"
|
mkdir -p "$work/$name"
|
||||||
( cd "$dir" && tar -cf - \
|
(cd "$dir" && tar -cf - \
|
||||||
--exclude='dist' --exclude='build' --exclude='rawfile' \
|
--exclude='dist' --exclude='build' --exclude='rawfile' \
|
||||||
--exclude='node_modules' --exclude='*.hmap' --exclude='.git' \
|
--exclude='node_modules' --exclude='*.hmap' --exclude='.git' \
|
||||||
. ) | ( cd "$work/$name" && tar -xf - )
|
.) | (cd "$work/$name" && tar -xf -)
|
||||||
|
|
||||||
# plg.json 里补 repository 字段,让装好的插件能反过来找到自己的仓库
|
# plg.json 里补 repository 字段,让装好的插件能反过来找到自己的仓库
|
||||||
# (plugin_update 靠它拉源码;不填的话插件永远无法自动更新)
|
# (plugin_update 靠它拉源码;不填的话插件永远无法自动更新)
|
||||||
@ -125,7 +133,7 @@ open(path, "a").write("\n")
|
|||||||
PY
|
PY
|
||||||
|
|
||||||
# 每个独立仓库都要有自己的 README 与 LICENSE 指引
|
# 每个独立仓库都要有自己的 README 与 LICENSE 指引
|
||||||
cat > "$work/$name/README.md" <<EOF
|
cat >"$work/$name/README.md" <<EOF
|
||||||
# $name
|
# $name
|
||||||
|
|
||||||
$desc
|
$desc
|
||||||
@ -162,7 +170,7 @@ plg.json 补 repo 字段,使 plugin_update 能定位本仓库。"
|
|||||||
git push -q origin main 2>&1 | tail -1 || echo " ✗ 推送失败(见上)"
|
git push -q origin main 2>&1 | tail -1 || echo " ✗ 推送失败(见上)"
|
||||||
)
|
)
|
||||||
echo " ✓ 已推送"
|
echo " ✓ 已推送"
|
||||||
pushed=$((pushed+1))
|
pushed=$((pushed + 1))
|
||||||
rm -rf "$work"
|
rm -rf "$work"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user