openclacky 1.3.9 → 1.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7208295135923baf898665ce973cbd07ec3e218437cf896bf3a6a2644b2d026
4
- data.tar.gz: ced24c041431be4afde0a6237afe250ca0876091c1b5f0ee3bf28eb4d166c152
3
+ metadata.gz: eccc2d78824c13072991199a00edd5b848216334422ce37876cd639854abb4ab
4
+ data.tar.gz: c29754fc48b5b9638168fa167c5335c32ccf5c64a3db94d3e64ac2638581857e
5
5
  SHA512:
6
- metadata.gz: eab17f40014529d41ab26f6aee158caee98f3d6a78eef6066bff86f65d24cd8cc949f20c4ce31e3a878c11bb0e70ee6210dbe68b2dbfcd2e4840dd041257e506
7
- data.tar.gz: cfdbd77b8bdd77d8a065fe3eea1222e6d272f792ad116ca511f50becb25f0db5690bb6683cddd8e1f33fe25228bacadaec32582d6d37daa3596d3d6133f19e10
6
+ metadata.gz: 6d2e6ef2dcf07a50779241a3ed5b384c2fbf34e4265208874567b12d1280e29f2fe79124286ab7b73251bfc836de01ebf9d8ad03f91ae12ad55ac4575128bfd3
7
+ data.tar.gz: 77088322e7cecc8c0cc0f7138786263c3de14dff2d1b33e99cee763437918e226bd34f0ef318a12d4f7292bcad5a935a8ca2ca16c9a15d8d2bd7262eebee9ccd
data/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.3.11] - 2026-07-12
8
+
9
+ ### Added
10
+ - Delete button for unpublished local extensions in ext-studio
11
+ - Extension install count shown in marketplace
12
+ - Update badge and installed version shown in marketplace list
13
+
14
+ ### Improved
15
+ - Extension version history styled with markdown rendering
16
+
17
+ ### Fixed
18
+ - Preserve user data on extension update (overwrite instead of replace)
19
+ - Extension install timeout increased to 300s (was too short for large extensions)
20
+ - Installed tab search and loading state in extension store
21
+ - Prevent license loss from non-atomic brand.yml writes
22
+ - Extension detail page content no longer cut off or unscrollable
23
+ - Extension author label position corrected to end of title row
24
+
25
+ ## [1.3.10] - 2026-07-09
26
+
27
+ ### Added
28
+ - Extension author shown inline in title row on extension cards
29
+ - Version input in publish modal — writes back to `ext.yml`, shows friendly Chinese error on version conflict
30
+ - Local extensions now included in the installed extensions list
31
+
32
+ ### Improved
33
+ - Published extensions list in publish panel — better style and UX
34
+ - Publishing state, success label, and done button added to publish modal flow
35
+ - OSS CDN prioritized for latest version check (faster upgrade detection)
36
+
37
+ ### Fixed
38
+ - Agent card description clamped to 2 lines; author hidden for built-in agents
39
+ - New-session scrollbar centering — use padding calc instead of max-width
40
+ - `file://` links with spaces and non-ASCII paths now render correctly (#350)
41
+ - Invalid API key error message improved to cover expired key case
42
+ - Local extension card in ext-studio now correctly appends action buttons
43
+ - Navigate back to list when extension detail fails to load
44
+ - Page reloads correctly after install/enable/disable/uninstall extension
45
+ - Enriched installed extensions with market data; shows unlisted badge
46
+
7
47
  ## [1.3.9] - 2026-07-08
8
48
 
9
49
  ### Added
@@ -92,17 +92,25 @@ module Clacky
92
92
  instance = new(data)
93
93
  instance.ensure_device_id!
94
94
  instance
95
- rescue StandardError
95
+ rescue StandardError => e
96
+ # A read/parse failure here is almost always a transient corruption (e.g. a
97
+ # concurrent write caught mid-truncate). Do NOT touch brand.yml — overwriting
98
+ # it with an empty config would permanently wipe the license, and even moving
99
+ # it aside could lose a file that self-heals a few milliseconds later. Leave
100
+ # the on-disk file untouched and return an in-memory-only config (device_id
101
+ # generated but never saved); the next load usually succeeds.
102
+ Clacky::Logger.error("[Brand] load failed, using in-memory fallback WITHOUT touching brand.yml: #{e.class}: #{e.message}")
96
103
  instance = new({})
97
- instance.ensure_device_id!
104
+ instance.ensure_device_id!(persist: false)
98
105
  instance
99
106
  end
100
107
 
101
- def ensure_device_id!
108
+ def ensure_device_id!(persist: true)
102
109
  return if @device_id && !@device_id.strip.empty?
103
110
 
111
+ Clacky::Logger.warn("[Brand] regenerating device_id (previous was blank; brand.yml may have been reset or corrupted)")
104
112
  @device_id = generate_device_id
105
- save
113
+ save if persist
106
114
  end
107
115
 
108
116
  # Returns true when this installation has a product name configured.
@@ -159,10 +167,28 @@ module Clacky
159
167
  end
160
168
 
161
169
  # Save current state to ~/.clacky/brand.yml
170
+ #
171
+ # Writes atomically: content goes to a per-process temp file that is fsynced
172
+ # and then renamed over the target. rename is atomic within a filesystem, so
173
+ # concurrent processes (server + CLI, or multiple servers) never observe a
174
+ # truncated or half-written file — which previously caused load to fall back
175
+ # to an empty config and wipe the license.
162
176
  def save
163
177
  FileUtils.mkdir_p(CONFIG_DIR)
164
- File.write(BRAND_FILE, to_yaml)
165
- FileUtils.chmod(0o600, BRAND_FILE)
178
+ Clacky::Logger.debug("[Brand] save: product_name=#{@product_name.inspect} license_key?=#{!@license_key.nil? && !@license_key.to_s.empty?} device_id?=#{!@device_id.nil? && !@device_id.to_s.empty?}")
179
+
180
+ tmp_path = "#{BRAND_FILE}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp"
181
+ begin
182
+ File.open(tmp_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
183
+ f.write(to_yaml)
184
+ f.flush
185
+ f.fsync
186
+ end
187
+ File.rename(tmp_path, BRAND_FILE)
188
+ rescue StandardError
189
+ File.delete(tmp_path) if File.exist?(tmp_path)
190
+ raise
191
+ end
166
192
  end
167
193
 
168
194
  # Remove the local license binding and wipe all brand-related fields from disk.
@@ -120,6 +120,26 @@ class ExtStudioExt < Clacky::ApiExtension
120
120
  json(extensions: exts)
121
121
  end
122
122
 
123
+ # DELETE /api/ext/ext-studio/local
124
+ # body: { ext_id }
125
+ # Permanently removes a local extension directory. Only allowed for
126
+ # unpublished extensions (caller must check; server enforces dir existence).
127
+ delete "/local" do
128
+ ext_id = require_ext_id!
129
+
130
+ result = Clacky::ExtensionLoader.load_all(force: false)
131
+ container = Array(result.containers).find { |id, _| id == ext_id }&.last
132
+ error!("extension not found: #{ext_id}", status: 404) unless container
133
+ error!("not a local extension", status: 422) unless container[:layer] == :local
134
+
135
+ dir = container[:dir]
136
+ error!("extension directory not found", status: 404) unless Dir.exist?(dir)
137
+
138
+ FileUtils.rm_rf(dir)
139
+ Clacky::ExtensionLoader.load_all(force: true)
140
+ json(ok: true, ext_id: ext_id)
141
+ end
142
+
123
143
  # POST /api/ext/ext-studio/unpublish
124
144
  # body: { ext_id }
125
145
  post "/unpublish" do
@@ -129,7 +149,33 @@ class ExtStudioExt < Clacky::ApiExtension
129
149
  json(ok: true, ext_id: ext_id)
130
150
  end
131
151
 
132
- # POST /api/ext/ext-studio/develop
152
+ # POST /api/ext/ext-studio/set_version
153
+ # body: { ext_id, version }
154
+ # Writes the new version string back to the local ext.yml.
155
+ post "/set_version" do
156
+ ext_id = require_ext_id!
157
+ version = presence(json_body["version"])
158
+ error!("version required", status: 422) unless version
159
+
160
+ result = Clacky::ExtensionLoader.load_all(force: false)
161
+ container = Array(result.containers).find { |id, _| id == ext_id }&.last
162
+ error!("extension not found: #{ext_id}", status: 404) unless container
163
+
164
+ yml_path = File.join(container[:dir], "ext.yml")
165
+ error!("ext.yml not found", status: 404) unless File.exist?(yml_path)
166
+
167
+ content = File.read(yml_path)
168
+ if content =~ /^version:/
169
+ content = content.sub(/^version:.*$/, "version: #{version}")
170
+ else
171
+ content = content.rstrip + "\nversion: #{version}\n"
172
+ end
173
+ File.write(yml_path, content)
174
+
175
+ json(ok: true, ext_id: ext_id, version: version)
176
+ end
177
+
178
+
133
179
  # body: { idea? }
134
180
  # Spawns a session bound to the ext-developer agent, optionally seeded with
135
181
  # the user's idea as the first task — the "let AI build it for me" entry.
@@ -19,8 +19,8 @@ contributes:
19
19
  - id: ext-developer
20
20
  title: Extension Developer
21
21
  title_zh: 扩展开发
22
- description: AI expert that helps you build, debug, and publish OpenClacky extensions
23
- description_zh: 帮你开发、调试、发布 OpenClacky 扩展的 AI 专家
22
+ description: AI expert that helps you build, debug, and publish extensions
23
+ description_zh: 帮你开发、调试、发布扩展的 AI 专家
24
24
  order: 5
25
25
  prompt: agents/ext-developer/system_prompt.md
26
26
  avatar: agents/ext-developer/avatar.png
@@ -48,9 +48,11 @@
48
48
  "btn.unpublish": "Unpublish",
49
49
  "published.confirm": "Unpublish {{id}} from the marketplace?",
50
50
  "err.generic": "Something went wrong: {{msg}}",
51
+ "err.version_conflict": "Version {{ver}} has already been published. Please enter a higher version number above and try again.",
52
+ "pub.version.label": "Version",
51
53
 
52
54
  "publish.confirm.title": "Publish to the marketplace?",
53
- "publish.confirm.body": "\"{{id}}\" will be packed and uploaded to the OpenClacky marketplace, where anyone can discover and install it. Make sure it's ready to share publicly.",
55
+ "publish.confirm.body": "\"{{id}}\" will be packed and uploaded to the extension marketplace, where anyone can discover and install it. Make sure it's ready to share publicly.",
54
56
  "publish.confirm.ok": "Publish",
55
57
  "publish.confirm.cancel": "Cancel",
56
58
  "bind.title": "Authorize this device",
@@ -67,7 +69,7 @@
67
69
 
68
70
  "pub.title.new": "Publish to the marketplace",
69
71
  "pub.title.update": "Publish a new version",
70
- "pub.intro": "\"{{name}}\" will be packed and uploaded to the OpenClacky marketplace, where anyone can discover and install it.",
72
+ "pub.intro": "\"{{name}}\" will be packed and uploaded to the extension marketplace, where anyone can discover and install it.",
71
73
  "pub.version.new": "First release · v{{ver}}",
72
74
  "pub.version.update": "v{{prev}} → v{{ver}}",
73
75
  "pub.version.missing": "No version in ext.yml — add a \"version\" field before publishing.",
@@ -77,10 +79,13 @@
77
79
  "pub.readme.ok": "README.md detected",
78
80
  "pub.readme.missing": "No README.md — consider adding one so users understand your extension.",
79
81
  "pub.btn.publish": "Publish v{{ver}}",
82
+ "pub.btn.publishing": "Publishing…",
83
+ "pub.btn.done": "Done",
80
84
  "pub.btn.cancel": "Cancel",
81
85
  "pub.progress.packing": "Packing…",
82
86
  "pub.progress.uploading": "Uploading to marketplace…",
83
87
  "pub.done": "Published {{id}} v{{ver}} — {{status}}",
88
+ "pub.success": "Published successfully",
84
89
  "pub.done.close": "Done",
85
90
  "pub.retry": "Try again",
86
91
  "extlist.section.cloud": "Published Extensions",
@@ -95,12 +100,14 @@
95
100
  "extlist.verify.ok": "Checks passed",
96
101
  "extlist.verify.errors": "{{n}} error(s)",
97
102
  "extlist.verify.warnings": "{{n}} warning(s)",
98
- "extlist.btn.publish": "Publish",
103
+ "extlist.btn.publish": "Publish to Marketplace",
99
104
  "extlist.btn.update": "Update",
100
- "extlist.btn.pack": "Pack",
105
+ "extlist.btn.pack": "Pack (.zip)",
101
106
  "extlist.btn.packing": "Packing…",
102
107
  "extlist.btn.unpublish": "Unpublish",
103
108
  "extlist.btn.iterate": "Iterate",
109
+ "extlist.btn.delete": "Delete",
110
+ "extlist.delete.confirm": "Delete local extension \"{{id}}\"? This cannot be undone.",
104
111
  "extlist.iterate.seed": "Iterate on extension {{id}}",
105
112
  "extlist.changelog.prompt": "Changelog (optional):",
106
113
  "extlist.overwrite.confirm": "\"{{id}}\" is already published. Publish a new version?",
@@ -132,7 +139,7 @@
132
139
  "skills.shadow.tooltip": "Local copy shadows a same-named brand skill",
133
140
  "skills.newSkill.label": "Create a new skill with /skill-creator",
134
141
  "skills.newSkill.btn": "Create New Skill",
135
- "skills.promo.text": "Publish your skills & build your own brand on OpenClacky.",
142
+ "skills.promo.text": "Publish your skills & build your own brand.",
136
143
  "skills.promo.link": "Learn more →",
137
144
  "skills.locked": "Creator license required to publish cloud skills.",
138
145
  "skills.publishing": "Publishing…",
@@ -178,9 +185,11 @@
178
185
  "btn.unpublish": "下架",
179
186
  "published.confirm": "确定要从市场下架 {{id}} 吗?",
180
187
  "err.generic": "出错了:{{msg}}",
188
+ "err.version_conflict": "版本 {{ver}} 已发布过,请在上方输入更高的版本号后重试。",
189
+ "pub.version.label": "版本号",
181
190
 
182
191
  "publish.confirm.title": "确定发布到市场?",
183
- "publish.confirm.body": "「{{id}}」将被打包并上传到 OpenClacky 市场,任何人都能发现并安装它。请确认它已准备好公开分享。",
192
+ "publish.confirm.body": "「{{id}}」将被打包并上传到扩展市场,任何人都能发现并安装它。请确认它已准备好公开分享。",
184
193
  "publish.confirm.ok": "发布",
185
194
  "publish.confirm.cancel": "取消",
186
195
  "bind.title": "授权此设备",
@@ -197,7 +206,7 @@
197
206
 
198
207
  "pub.title.new": "发布到市场",
199
208
  "pub.title.update": "发布新版本",
200
- "pub.intro": "「{{name}}」将被打包并上传到 OpenClacky 市场,任何人都能发现并安装它。",
209
+ "pub.intro": "「{{name}}」将被打包并上传到扩展市场,任何人都能发现并安装它。",
201
210
  "pub.version.new": "首次发布 · v{{ver}}",
202
211
  "pub.version.update": "v{{prev}} → v{{ver}}",
203
212
  "pub.version.missing": "ext.yml 里没有 version — 请先补上 version 字段再发布。",
@@ -207,10 +216,13 @@
207
216
  "pub.readme.ok": "已检测到 README.md",
208
217
  "pub.readme.missing": "未找到 README.md — 建议补一份,方便用户了解你的扩展。",
209
218
  "pub.btn.publish": "发布 v{{ver}}",
219
+ "pub.btn.publishing": "发布中…",
220
+ "pub.btn.done": "完成",
210
221
  "pub.btn.cancel": "取消",
211
222
  "pub.progress.packing": "打包中…",
212
223
  "pub.progress.uploading": "上传到市场…",
213
224
  "pub.done": "已发布 {{id}} v{{ver}} — {{status}}",
225
+ "pub.success": "已发布成功",
214
226
  "pub.done.close": "完成",
215
227
  "pub.retry": "重试",
216
228
  "extlist.section.cloud": "已发布扩展",
@@ -225,12 +237,14 @@
225
237
  "extlist.verify.ok": "检查通过",
226
238
  "extlist.verify.errors": "{{n}} 个错误",
227
239
  "extlist.verify.warnings": "{{n}} 个警告",
228
- "extlist.btn.publish": "发布",
229
- "extlist.btn.update": "更新",
230
- "extlist.btn.pack": "打包",
240
+ "extlist.btn.publish": "发布到市场",
241
+ "extlist.btn.update": "更新到市场",
242
+ "extlist.btn.pack": "打包(.zip)",
231
243
  "extlist.btn.packing": "打包中…",
232
244
  "extlist.btn.unpublish": "下架",
233
245
  "extlist.btn.iterate": "迭代",
246
+ "extlist.btn.delete": "删除",
247
+ "extlist.delete.confirm": "确定要删除本地扩展「{{id}}」吗?此操作不可恢复。",
234
248
  "extlist.iterate.seed": "迭代扩展 {{id}}",
235
249
  "extlist.changelog.prompt": "更新说明(可选):",
236
250
  "extlist.overwrite.confirm": "「{{id}}」已经发布过了。要发布新版本吗?",
@@ -262,7 +276,7 @@
262
276
  "skills.shadow.tooltip": "本地副本覆盖了同名品牌 skill",
263
277
  "skills.newSkill.label": "用 /skill-creator 创建新 skill",
264
278
  "skills.newSkill.btn": "创建新 Skill",
265
- "skills.promo.text": "发布你的 skill,在 OpenClacky 上打造自己的品牌。",
279
+ "skills.promo.text": "发布你的 skill,打造自己的品牌。",
266
280
  "skills.promo.link": "了解更多 →",
267
281
  "skills.locked": "发布云端 skill 需要创作者授权。",
268
282
  "skills.publishing": "发布中…",
@@ -465,44 +479,48 @@
465
479
 
466
480
  // Unified publish flow: one modal that walks the creator from a release form
467
481
  // (version + notes) through progress → device binding (if needed) → done.
468
- // The creator owns the version in ext.yml; a new release must be greater than
469
- // the currently published version — the backend enforces that. `prevVersion`
470
- // is the latest published version when updating, or null for a first release.
482
+ // The creator owns the version; if it conflicts with the latest published
483
+ // version they can bump it inline — the modal writes it back to ext.yml
484
+ // before uploading. `prevVersionOrPromise` may be a value or a Promise.
471
485
  // Resolves true when a publish succeeded, false otherwise.
472
- function runPublishFlow(ext, prevVersion) {
486
+ function runPublishFlow(ext, prevVersionOrPromise) {
473
487
  return new Promise((resolve) => {
474
- const version = ext.version;
475
- const isUpdate = !!prevVersion;
476
-
477
- const intro = el("p", { class: "studio-modal-intro", text: t("pub.intro", { name: ext.name }) });
478
-
479
- const verText = !version
480
- ? t("pub.version.missing")
481
- : isUpdate
482
- ? t("pub.version.update", { prev: prevVersion, ver: version })
483
- : t("pub.version.new", { ver: version });
484
-
485
- const metaBlock = el("div", { class: "studio-pub-meta" });
486
- metaBlock.appendChild(el("span", { class: "studio-pub-version", text: verText }));
488
+ let currentVersion = ext.version || "";
489
+ let isUpdate = false;
490
+
491
+ const verField = el("div", { class: "studio-field" });
492
+ verField.appendChild(el("label", { class: "studio-label", text: t("pub.version.label") }));
493
+ const verInput = el("input", { class: "studio-input", type: "text", value: currentVersion, placeholder: "1.0.0" });
494
+ verInput.addEventListener("input", () => {
495
+ currentVersion = verInput.value.trim();
496
+ publishBtn.disabled = !currentVersion;
497
+ publishBtn.textContent = currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" });
498
+ });
499
+ verField.appendChild(verInput);
487
500
 
488
501
  const notesField = el("div", { class: "studio-field" });
489
502
  notesField.appendChild(el("label", { class: "studio-label", text: t("pub.notes.label") }));
490
- const notes = el("textarea", { class: "studio-textarea", rows: "3", placeholder: t("pub.notes.placeholder") });
503
+ const notes = el("textarea", { class: "studio-textarea", rows: "4", placeholder: t("pub.notes.placeholder") });
491
504
  notesField.appendChild(notes);
492
505
 
493
506
  const status = el("p", { class: "studio-modal-status" });
494
507
  status.style.display = "none";
495
508
 
496
- const bodyChildren = [intro, metaBlock, notesField, status];
509
+ const bodyChildren = [
510
+ el("p", { class: "studio-modal-intro", text: t("pub.intro", { name: ext.name }) }),
511
+ verField,
512
+ notesField,
513
+ status,
514
+ ];
497
515
 
498
516
  let done = false;
499
517
  const modal = openModal({
500
- title: isUpdate ? t("pub.title.update") : t("pub.title.new"),
518
+ title: t("pub.title.new"),
501
519
  body: el("div", null, bodyChildren),
502
520
  buttons: [
503
521
  { label: t("pub.btn.cancel"), keepOpen: false, onClick: () => { if (!done) resolve(false); } },
504
522
  {
505
- label: version ? t("pub.btn.publish", { ver: version }) : t("pub.btn.publish", { ver: "?" }),
523
+ label: currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" }),
506
524
  primary: true,
507
525
  keepOpen: true,
508
526
  onClick: () => submit(),
@@ -512,50 +530,82 @@
512
530
 
513
531
  const publishBtn = modal.footer.querySelector(".studio-btn-primary");
514
532
  const cancelBtn = modal.footer.querySelector(".studio-btn:not(.studio-btn-primary)");
515
- if (!version) publishBtn.disabled = true;
533
+ if (!currentVersion) publishBtn.disabled = true;
534
+
535
+ Promise.resolve(prevVersionOrPromise).then((prevVersion) => {
536
+ if (done || !prevVersion) return;
537
+ isUpdate = true;
538
+ const titleEl = modal.overlay.querySelector(".studio-modal-title");
539
+ if (titleEl) titleEl.textContent = t("pub.title.update");
540
+ if (prevVersion !== currentVersion) {
541
+ currentVersion = prevVersion;
542
+ verInput.value = currentVersion;
543
+ publishBtn.disabled = false;
544
+ publishBtn.textContent = t("pub.btn.publish", { ver: currentVersion });
545
+ }
546
+ });
516
547
 
517
- function setProgress(msg) {
548
+ function setProgress(msg, isError) {
518
549
  status.style.display = "";
519
550
  status.textContent = msg;
551
+ status.className = "studio-modal-status" + (isError ? " studio-modal-status-error" : "");
552
+ }
553
+
554
+ function resetButtons() {
555
+ publishBtn.disabled = !currentVersion;
556
+ publishBtn.textContent = currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" });
557
+ cancelBtn.disabled = false;
558
+ notes.disabled = false;
559
+ verInput.disabled = false;
520
560
  }
521
561
 
522
562
  async function submit() {
523
563
  if (done || publishBtn.disabled) return;
564
+ const ver = currentVersion;
565
+ if (!ver) return;
566
+
524
567
  publishBtn.disabled = true;
568
+ publishBtn.textContent = t("pub.btn.publishing");
525
569
  cancelBtn.disabled = true;
526
570
  notes.disabled = true;
527
- setProgress(t("pub.progress.packing"));
571
+ verInput.disabled = true;
572
+ status.style.display = "none";
573
+
528
574
  try {
529
- setProgress(t("pub.progress.uploading"));
575
+ if (ver !== ext.version) {
576
+ await postJson("/set_version", { ext_id: ext.id, version: ver });
577
+ }
578
+
530
579
  const data = await publishWithBinding({
531
580
  ext_id: ext.id,
532
581
  force: isUpdate,
533
582
  changelog: notes.value.trim(),
534
583
  });
535
- if (data === null) {
536
- publishBtn.disabled = false;
537
- cancelBtn.disabled = false;
538
- notes.disabled = false;
539
- status.style.display = "none";
540
- return;
541
- }
584
+
585
+ if (data === null) { resetButtons(); return; }
542
586
  if (!data.ok) throw new Error(data.error || "Publish failed");
587
+
543
588
  done = true;
544
- setProgress(t("pub.done", {
545
- id: data.ext_id,
546
- ver: data.version || version,
547
- status: data.status || "",
548
- }));
549
- publishBtn.style.display = "none";
550
- cancelBtn.textContent = t("pub.done.close");
551
- cancelBtn.disabled = false;
552
- cancelBtn.onclick = () => { modal.close(); resolve(true); };
553
- } catch (e) {
554
- setProgress(t("err.generic", { msg: e.message }));
589
+ const successLabel = el("span", { class: "studio-pub-success", text: t("pub.success") });
590
+ modal.footer.insertBefore(successLabel, modal.footer.firstChild);
591
+ cancelBtn.style.display = "none";
555
592
  publishBtn.disabled = false;
556
- publishBtn.textContent = t("pub.retry");
557
- cancelBtn.disabled = false;
558
- notes.disabled = false;
593
+ publishBtn.textContent = t("pub.btn.done");
594
+ publishBtn.onclick = () => { modal.close(); resolve(true); };
595
+ } catch (e) {
596
+ const msg = e.message || "";
597
+ const isConflict = /must be greater than/i.test(msg);
598
+ if (isConflict) {
599
+ setProgress(t("err.version_conflict", { ver }), true);
600
+ verInput.classList.add("studio-input-error");
601
+ verInput.disabled = false;
602
+ verInput.focus();
603
+ verInput.select();
604
+ verInput.addEventListener("input", () => verInput.classList.remove("studio-input-error"), { once: true });
605
+ } else {
606
+ setProgress(t("err.generic", { msg }), true);
607
+ }
608
+ resetButtons();
559
609
  }
560
610
  }
561
611
  });
@@ -739,13 +789,13 @@
739
789
  async function doPublish() {
740
790
  const ext = store.selected();
741
791
  if (!ext) return;
742
- let prevVersion = null;
743
- try {
744
- const data = await getJson("/published");
745
- const match = (data.extensions || []).find((e) => e.id === ext.id);
746
- prevVersion = match ? match.version : null;
747
- } catch (_e) { /* treat as first release */ }
748
- const ok = await runPublishFlow(ext, prevVersion);
792
+ const prevVersionPromise = getJson("/published")
793
+ .then((data) => {
794
+ const match = (data.extensions || []).find((e) => e.id === ext.id);
795
+ return match ? match.version : null;
796
+ })
797
+ .catch(() => null);
798
+ const ok = await runPublishFlow(ext, prevVersionPromise);
749
799
  if (ok) loadPublished();
750
800
  }
751
801
 
@@ -774,17 +824,23 @@
774
824
  const exts = data.extensions || [];
775
825
  if (!exts.length) { box.appendChild(el("p", { class: "studio-empty", text: t("published.empty") })); return; }
776
826
  exts.forEach((e) => {
777
- const row = el("div", { class: "studio-published-row" });
778
- row.appendChild(el("span", { class: "studio-published-name", text: `${e.name}${e.version ? " v" + e.version : ""} [${e.status}]` }));
779
- row.appendChild(el("button", {
780
- class: "studio-btn studio-btn-danger", text: t("btn.unpublish"),
781
- onclick: async () => {
782
- if (!window.confirm(t("published.confirm", { id: e.id }))) return;
783
- try { await postJson("/unpublish", { ext_id: e.id }); loadPublished(); }
784
- catch (err) { feedback(t("err.generic", { msg: err.message }), "error"); }
785
- },
786
- }));
787
- box.appendChild(row);
827
+ const card = el("div", { class: "studio-skill-card" });
828
+ const head = el("div", { class: "studio-skill-head" });
829
+ head.appendChild(el("span", { class: "studio-skill-name", text: e.name || e.id }));
830
+ const statusKind = e.status === "draft" ? "local" : "published";
831
+ head.appendChild(el("span", { class: `studio-skill-badge studio-skill-badge-${statusKind}`, text: e.status === "draft" ? t("extlist.badge.draft") : t("extlist.badge.published") }));
832
+ if (e.version) head.appendChild(el("span", { style: "font-size:11px;color:var(--color-text-muted);", text: "v" + e.version }));
833
+ const unpubBtn = el("button", { class: "studio-btn studio-btn-danger", text: t("btn.unpublish") });
834
+ unpubBtn.style.marginLeft = "auto";
835
+ unpubBtn.addEventListener("click", async () => {
836
+ if (!window.confirm(t("published.confirm", { id: e.id }))) return;
837
+ unpubBtn.disabled = true;
838
+ try { await postJson("/unpublish", { ext_id: e.id }); loadPublished(); }
839
+ catch (err) { unpubBtn.disabled = false; feedback(t("err.generic", { msg: err.message }), "error"); }
840
+ });
841
+ head.appendChild(unpubBtn);
842
+ card.appendChild(head);
843
+ box.appendChild(card);
788
844
  });
789
845
  } catch (e) {
790
846
  box.appendChild(el("p", { class: "studio-empty", text: t("err.generic", { msg: e.message }) }));
@@ -927,6 +983,13 @@
927
983
  const packBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.pack") });
928
984
  packBtn.addEventListener("click", () => doPack(ext, packBtn));
929
985
  actions.appendChild(packBtn);
986
+ card.appendChild(actions);
987
+
988
+ if (!published) {
989
+ const delBtn = el("button", { class: "studio-btn studio-btn-danger", text: t("extlist.btn.delete") });
990
+ delBtn.addEventListener("click", () => doDelete(ext, delBtn));
991
+ actions.appendChild(delBtn);
992
+ }
930
993
  return card;
931
994
  }
932
995
 
@@ -942,6 +1005,25 @@
942
1005
  }
943
1006
  }
944
1007
 
1008
+ async function doDelete(ext, btn) {
1009
+ if (!confirm(t("extlist.delete.confirm", { id: ext.id }))) return;
1010
+ btn.disabled = true;
1011
+ try {
1012
+ const res = await fetch("/api/ext/ext-studio/local", {
1013
+ method: "DELETE",
1014
+ headers: { "Content-Type": "application/json" },
1015
+ body: JSON.stringify({ ext_id: ext.id }),
1016
+ });
1017
+ let data = {};
1018
+ try { data = await res.json(); } catch (_e) {}
1019
+ if (!res.ok) throw new Error(data?.error || `Error ${res.status}`);
1020
+ await reload(); rebuild();
1021
+ } catch (e) {
1022
+ alert(t("err.generic", { msg: e.message }));
1023
+ btn.disabled = false;
1024
+ }
1025
+ }
1026
+
945
1027
  async function doPublish(ext, prevVersion) {
946
1028
  const ok = await runPublishFlow(ext, prevVersion);
947
1029
  if (ok) { await reload(); rebuild(); }
@@ -1269,7 +1351,7 @@
1269
1351
  .studio-btn-primary { background: var(--color-button-primary); color: var(--color-button-primary-text); border-color: transparent; }
1270
1352
  .studio-btn-primary:hover { background: var(--color-button-primary-hover); color: var(--color-button-primary-text); }
1271
1353
  .studio-btn-primary:disabled { opacity: 0.6; cursor: default; }
1272
- .studio-btn-danger { color: var(--color-error); border-color: var(--color-error-border); padding: 4px 10px; }
1354
+ .studio-btn-danger { color: var(--color-error); border-color: var(--color-error-border); }
1273
1355
  .studio-btn-danger:hover { background: var(--color-error-bg); color: var(--color-error); }
1274
1356
  .studio-btn-ghost { color: var(--color-text-secondary); font-weight: 500; }
1275
1357
  .studio-btn-ghost:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
@@ -1291,8 +1373,8 @@
1291
1373
  .studio-feedback-error { color: var(--color-error); }
1292
1374
  .studio-feedback-warn { color: var(--color-warning, var(--color-text-secondary)); }
1293
1375
  .studio-published { margin-top: 16px; border-top: 1px solid var(--color-border-primary); padding-top: 12px; }
1294
- .studio-published-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 0; font-size: 12px; }
1295
- .studio-published-name { color: var(--color-text-primary); }
1376
+ .studio-published-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 0; font-size: 12px; border-bottom: 1px solid var(--color-border-primary); }
1377
+ .studio-published-name { display: flex; align-items: center; gap: 6px; color: var(--color-text-primary); }
1296
1378
  .studio-page { width: 100%; }
1297
1379
  .studio-page-head { margin-bottom: 20px; }
1298
1380
  .studio-page-title { margin: 0; font-size: 22px; font-weight: 600; color: var(--color-text-primary); }
@@ -1305,7 +1387,7 @@
1305
1387
  .studio-skill-section { margin-bottom: 28px; }
1306
1388
  .studio-skill-section-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
1307
1389
  .studio-skill-hint { font-size: 11px; color: var(--color-text-muted); }
1308
- .studio-skill-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding: 14px 16px; margin: 0 0 10px; background: var(--color-bg-secondary); }
1390
+ .studio-skill-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding: 12px; margin: 0 0 10px; background: var(--color-bg-secondary); }
1309
1391
  .studio-skill-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
1310
1392
  .studio-skill-name { font-size: 14px; font-weight: 600; color: var(--color-text-primary); }
1311
1393
  .studio-skill-badge { font-size: 11px; padding: 1px 8px; border-radius: 10px; }
@@ -1314,7 +1396,7 @@
1314
1396
  .studio-skill-badge-changed { background: var(--color-warning-bg, var(--color-bg-hover)); color: var(--color-warning, var(--color-text-secondary)); }
1315
1397
  .studio-skill-badge-shadow { background: var(--color-bg-hover); color: var(--color-text-secondary); }
1316
1398
  .studio-skill-desc { margin: 6px 0 8px; font-size: 12px; color: var(--color-text-secondary); line-height: 1.5; }
1317
- .studio-skill-meta { display: flex; gap: 12px; font-size: 11px; color: var(--color-text-muted); margin-bottom: 8px; }
1399
+ .studio-skill-meta { display: flex; gap: 12px; font-size: 11px; color: var(--color-text-muted); }
1318
1400
  .studio-skill-promo { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding: 16px 18px; margin: 0 0 24px; background: var(--color-bg-secondary); }
1319
1401
  .studio-skill-promo-text { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: var(--color-text-primary); }
1320
1402
  .studio-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999; animation: studio-overlay-in 0.15s ease; }
@@ -1329,9 +1411,14 @@
1329
1411
  .studio-pub-units::before { content: "·"; margin-right: 10px; }
1330
1412
  .studio-modal-body { font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; }
1331
1413
  .studio-modal-status { margin: 0 0 8px; font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; }
1414
+ .studio-modal-status-error { color: var(--color-error); }
1415
+ .studio-input { width: 100%; box-sizing: border-box; background: var(--color-bg-input); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 7px 10px; color: var(--color-text-primary); font-size: 13px; font-family: monospace; }
1416
+ .studio-input:focus { border-color: var(--color-accent-primary); outline: none; }
1417
+ .studio-input-error { border-color: var(--color-error) !important; }
1332
1418
  .studio-modal-code { margin: 0 0 8px; font-size: 13px; font-family: monospace; color: var(--color-text-primary); }
1333
1419
  .studio-modal-link { display: inline-block; font-size: 13px; color: var(--color-accent-primary); }
1334
1420
  .studio-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--color-border-primary); }
1421
+ .studio-pub-success { margin-right: auto; font-size: 13px; font-weight: 500; color: var(--color-success); }
1335
1422
  `;
1336
1423
  document.head.appendChild(style);
1337
1424
  })();