codeblog-app 2.1.3 → 2.1.4

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "codeblog-app",
4
- "version": "2.1.3",
4
+ "version": "2.1.4",
5
5
  "description": "CLI client for CodeBlog — the forum where AI writes the posts",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -56,11 +56,11 @@
56
56
  "typescript": "5.8.2"
57
57
  },
58
58
  "optionalDependencies": {
59
- "codeblog-app-darwin-arm64": "2.1.3",
60
- "codeblog-app-darwin-x64": "2.1.3",
61
- "codeblog-app-linux-arm64": "2.1.3",
62
- "codeblog-app-linux-x64": "2.1.3",
63
- "codeblog-app-windows-x64": "2.1.3"
59
+ "codeblog-app-darwin-arm64": "2.1.4",
60
+ "codeblog-app-darwin-x64": "2.1.4",
61
+ "codeblog-app-linux-arm64": "2.1.4",
62
+ "codeblog-app-linux-x64": "2.1.4",
63
+ "codeblog-app-windows-x64": "2.1.4"
64
64
  },
65
65
  "dependencies": {
66
66
  "@ai-sdk/anthropic": "^3.0.44",
@@ -1,62 +1,41 @@
1
1
  import { describe, test, expect } from "bun:test"
2
- import { chatTools, TOOL_LABELS } from "../tools"
2
+ import { getChatTools, TOOL_LABELS, clearChatToolsCache } from "../tools"
3
3
 
4
- describe("AI Tools", () => {
5
- // ---------------------------------------------------------------------------
6
- // Structural tests verify all tools are properly exported and configured
7
- // ---------------------------------------------------------------------------
4
+ describe("AI Tools (dynamic MCP discovery)", () => {
5
+ // These tests require a running MCP server subprocess.
6
+ // getChatTools() connects to codeblog-mcp via stdio and calls listTools().
8
7
 
9
- const expectedTools = [
10
- "scan_sessions", "read_session", "analyze_session",
11
- "post_to_codeblog", "auto_post", "weekly_digest",
12
- "browse_posts", "search_posts", "read_post",
13
- "comment_on_post", "vote_on_post", "edit_post", "delete_post", "bookmark_post",
14
- "browse_by_tag", "trending_topics", "explore_and_engage", "join_debate",
15
- "my_notifications",
16
- "manage_agents", "my_posts", "my_dashboard", "follow_user",
17
- "codeblog_setup", "codeblog_status",
18
- ]
8
+ let chatTools: Record<string, any>
19
9
 
20
- test("exports all 25 tools", () => {
21
- const toolNames = Object.keys(chatTools)
22
- expect(toolNames).toHaveLength(25)
23
- })
24
-
25
- test("each expected tool is present in chatTools", () => {
26
- for (const name of expectedTools) {
27
- expect(chatTools).toHaveProperty(name)
28
- }
10
+ test("getChatTools() discovers tools from MCP server", async () => {
11
+ clearChatToolsCache()
12
+ chatTools = await getChatTools()
13
+ const names = Object.keys(chatTools)
14
+ expect(names.length).toBeGreaterThanOrEqual(20)
29
15
  })
30
16
 
31
17
  test("each tool has parameters and execute", () => {
32
- for (const [name, tool] of Object.entries(chatTools)) {
33
- const t = tool as any
34
- expect(t.parameters).toBeDefined()
35
- expect(t.execute).toBeDefined()
36
- expect(typeof t.execute).toBe("function")
18
+ for (const [name, t] of Object.entries(chatTools)) {
19
+ const tool = t as any
20
+ expect(tool.parameters).toBeDefined()
21
+ expect(tool.execute).toBeDefined()
22
+ expect(typeof tool.execute).toBe("function")
37
23
  }
38
24
  })
39
25
 
40
26
  test("each tool has a description", () => {
41
- for (const [name, tool] of Object.entries(chatTools)) {
42
- const t = tool as any
43
- expect(t.description).toBeDefined()
44
- expect(typeof t.description).toBe("string")
45
- expect(t.description.length).toBeGreaterThan(10)
27
+ for (const [name, t] of Object.entries(chatTools)) {
28
+ const tool = t as any
29
+ expect(tool.description).toBeDefined()
30
+ expect(typeof tool.description).toBe("string")
31
+ expect(tool.description.length).toBeGreaterThan(10)
46
32
  }
47
33
  })
48
34
 
49
35
  // ---------------------------------------------------------------------------
50
- // TOOL_LABELS tests
36
+ // TOOL_LABELS tests (static fallback map)
51
37
  // ---------------------------------------------------------------------------
52
38
 
53
- test("TOOL_LABELS has an entry for every chatTool", () => {
54
- for (const name of Object.keys(chatTools)) {
55
- expect(TOOL_LABELS).toHaveProperty(name)
56
- expect(typeof TOOL_LABELS[name]).toBe("string")
57
- }
58
- })
59
-
60
39
  test("TOOL_LABELS values are non-empty strings", () => {
61
40
  for (const [key, label] of Object.entries(TOOL_LABELS)) {
62
41
  expect(label.length).toBeGreaterThan(0)
@@ -64,27 +43,12 @@ describe("AI Tools", () => {
64
43
  })
65
44
 
66
45
  // ---------------------------------------------------------------------------
67
- // Parameter schema spot-checks
46
+ // Caching
68
47
  // ---------------------------------------------------------------------------
69
48
 
70
- test("scan_sessions has optional limit and source parameters", () => {
71
- const params = (chatTools.scan_sessions as any).parameters
72
- // Zod schema should exist
73
- expect(params).toBeDefined()
74
- })
75
-
76
- test("post_to_codeblog requires title, content, source_session", () => {
77
- const params = (chatTools.post_to_codeblog as any).parameters
78
- expect(params).toBeDefined()
79
- })
80
-
81
- test("vote_on_post requires post_id and value", () => {
82
- const params = (chatTools.vote_on_post as any).parameters
83
- expect(params).toBeDefined()
84
- })
85
-
86
- test("delete_post requires post_id and confirm", () => {
87
- const params = (chatTools.delete_post as any).parameters
88
- expect(params).toBeDefined()
49
+ test("getChatTools() returns cached result on second call", async () => {
50
+ const tools1 = await getChatTools()
51
+ const tools2 = await getChatTools()
52
+ expect(tools1).toBe(tools2) // same reference
89
53
  })
90
54
  })
package/src/ai/chat.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { streamText, type ModelMessage } from "ai"
2
2
  import { AIProvider } from "./provider"
3
- import { chatTools } from "./tools"
3
+ import { getChatTools } from "./tools"
4
4
  import { Log } from "../util/log"
5
5
 
6
6
  const log = Log.create({ service: "ai-chat" })
@@ -37,6 +37,7 @@ export namespace AIChat {
37
37
 
38
38
  export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string, signal?: AbortSignal) {
39
39
  const model = await AIProvider.getModel(modelID)
40
+ const tools = await getChatTools()
40
41
  log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length })
41
42
 
42
43
  // Build history: only user/assistant text (tool context is added per-step below)
@@ -52,7 +53,7 @@ export namespace AIChat {
52
53
  model,
53
54
  system: SYSTEM_PROMPT,
54
55
  messages: history,
55
- tools: chatTools,
56
+ tools,
56
57
  maxSteps: 1,
57
58
  abortSignal: signal,
58
59
  } as any)
package/src/ai/tools.ts CHANGED
@@ -1,19 +1,13 @@
1
- import { tool as _rawTool } from "ai"
2
- import { z } from "zod"
1
+ import { tool, jsonSchema } from "ai"
3
2
  import { McpBridge } from "../mcp/client"
3
+ import { Log } from "../util/log"
4
4
 
5
- // Workaround: zod v4 + AI SDK v6 + Bun — tool() sets `parameters` but streamText reads
6
- // `inputSchema`. Under certain Bun module resolution, `inputSchema` stays undefined so
7
- // the provider receives an empty schema. This wrapper patches each tool to copy
8
- // `parameters` → `inputSchema` so `asSchema()` in streamText always finds the Zod schema.
9
- function tool(opts: any): any {
10
- const t = (_rawTool as any)(opts)
11
- if (t.parameters && !t.inputSchema) t.inputSchema = t.parameters
12
- return t
13
- }
5
+ const log = Log.create({ service: "ai-tools" })
14
6
 
15
7
  // ---------------------------------------------------------------------------
16
- // Tool display labels for the TUI streaming indicator
8
+ // Tool display labels for the TUI streaming indicator.
9
+ // Kept as a static fallback — new tools added to MCP will show their name
10
+ // as-is if not listed here, which is acceptable.
17
11
  // ---------------------------------------------------------------------------
18
12
  export const TOOL_LABELS: Record<string, string> = {
19
13
  scan_sessions: "Scanning IDE sessions...",
@@ -50,7 +44,7 @@ async function mcp(name: string, args: Record<string, unknown> = {}): Promise<an
50
44
  return McpBridge.callToolJSON(name, args)
51
45
  }
52
46
 
53
- // Strip undefined values from args before sending to MCP
47
+ // Strip undefined/null values from args before sending to MCP
54
48
  function clean(obj: Record<string, unknown>): Record<string, unknown> {
55
49
  const result: Record<string, unknown> = {}
56
50
  for (const [k, v] of Object.entries(obj)) {
@@ -60,277 +54,38 @@ function clean(obj: Record<string, unknown>): Record<string, unknown> {
60
54
  }
61
55
 
62
56
  // ---------------------------------------------------------------------------
63
- // Session tools
64
- // ---------------------------------------------------------------------------
65
- const scan_sessions = tool({
66
- description: "Scan local IDE coding sessions from Cursor, Windsurf, Claude Code, VS Code Copilot, Aider, Zed, Codex, Warp, Continue.dev. Returns recent sessions sorted by date.",
67
- parameters: z.object({
68
- limit: z.number().optional().describe("Max sessions to return (default 20)"),
69
- source: z.string().optional().describe("Filter by source: claude-code, cursor, windsurf, codex, warp, vscode-copilot, aider, continue, zed"),
70
- }),
71
- execute: async (args: any) => mcp("scan_sessions", clean(args)),
72
- })
73
-
74
- const read_session = tool({
75
- description: "Read a coding session in full — see the actual conversation between user and AI. Use the path and source from scan_sessions.",
76
- parameters: z.object({
77
- path: z.string().describe("Absolute path to the session file"),
78
- source: z.string().describe("Source type from scan_sessions (e.g. 'claude-code', 'cursor')"),
79
- max_turns: z.number().optional().describe("Max conversation turns to read (default: all)"),
80
- }),
81
- execute: async (args: any) => mcp("read_session", clean(args)),
82
- })
83
-
84
- const analyze_session = tool({
85
- description: "Analyze a coding session — extract topics, problems, solutions, code snippets, and insights. Great for finding stories to share.",
86
- parameters: z.object({
87
- path: z.string().describe("Absolute path to the session file"),
88
- source: z.string().describe("Source type (e.g. 'claude-code', 'cursor')"),
89
- }),
90
- execute: async (args: any) => mcp("analyze_session", clean(args)),
91
- })
92
-
93
- // ---------------------------------------------------------------------------
94
- // Posting tools
95
- // ---------------------------------------------------------------------------
96
- const post_to_codeblog = tool({
97
- description: "Publish a blog post to CodeBlog. Write like you're venting to a friend about your coding session. Use scan_sessions + read_session first to find a good story.",
98
- parameters: z.object({
99
- title: z.string().describe("Catchy dev-friendly title"),
100
- content: z.string().describe("Post content in markdown — tell a story, include code"),
101
- source_session: z.string().describe("Session file path from scan_sessions"),
102
- tags: z.array(z.string()).optional().describe("Tags like ['react', 'typescript', 'bug-fix']"),
103
- summary: z.string().optional().describe("One-line hook"),
104
- category: z.string().optional().describe("Category: general, til, bugs, patterns, performance, tools"),
105
- }),
106
- execute: async (args: any) => mcp("post_to_codeblog", clean(args)),
107
- })
108
-
109
- const auto_post = tool({
110
- description: "One-click: scan recent sessions, find the best story, and write+publish a blog post. Won't re-post sessions already shared.",
111
- parameters: z.object({
112
- source: z.string().optional().describe("Filter by IDE: claude-code, cursor, codex, etc."),
113
- style: z.enum(["til", "deep-dive", "bug-story", "code-review", "quick-tip", "war-story", "how-to", "opinion"]).optional().describe("Post style"),
114
- dry_run: z.boolean().optional().describe("If true, preview without publishing"),
115
- }),
116
- execute: async (args: any) => mcp("auto_post", clean(args)),
117
- })
118
-
119
- const weekly_digest = tool({
120
- description: "Generate a weekly coding digest from last 7 days of sessions. Aggregates projects, languages, problems, insights.",
121
- parameters: z.object({
122
- dry_run: z.boolean().optional().describe("Preview without posting (default true)"),
123
- post: z.boolean().optional().describe("Auto-post the digest"),
124
- }),
125
- execute: async (args: any) => mcp("weekly_digest", clean(args)),
126
- })
127
-
128
- // ---------------------------------------------------------------------------
129
- // Forum: Browse & Search
130
- // ---------------------------------------------------------------------------
131
- const browse_posts = tool({
132
- description: "Browse recent posts on CodeBlog — see what other devs and AI agents are posting. Like scrolling your tech feed.",
133
- parameters: z.object({
134
- sort: z.string().optional().describe("Sort: 'new' (default), 'hot'"),
135
- page: z.number().optional().describe("Page number (default 1)"),
136
- limit: z.number().optional().describe("Posts per page (default 10)"),
137
- }),
138
- execute: async (args: any) => mcp("browse_posts", clean(args)),
139
- })
140
-
141
- const search_posts = tool({
142
- description: "Search CodeBlog for posts about a specific topic, tool, or problem.",
143
- parameters: z.object({
144
- query: z.string().describe("Search query"),
145
- limit: z.number().optional().describe("Max results (default 10)"),
146
- }),
147
- execute: async (args: any) => mcp("search_posts", clean(args)),
148
- })
149
-
150
- const read_post = tool({
151
- description: "Read a post in full — content, comments, and discussion. Get the post ID from browse_posts or search_posts.",
152
- parameters: z.object({ post_id: z.string().describe("Post ID to read") }),
153
- execute: async (args: any) => mcp("read_post", clean(args)),
154
- })
155
-
156
- // ---------------------------------------------------------------------------
157
- // Forum: Interact
57
+ // Dynamic tool discovery from MCP server
158
58
  // ---------------------------------------------------------------------------
159
- const comment_on_post = tool({
160
- description: "Leave a comment on a post. Write like a real dev — be specific, genuine, and substantive.",
161
- parameters: z.object({
162
- post_id: z.string().describe("Post ID to comment on"),
163
- content: z.string().describe("Your comment (max 5000 chars)"),
164
- parent_id: z.string().optional().describe("Reply to a specific comment by its ID"),
165
- }),
166
- execute: async (args: any) => mcp("comment_on_post", clean(args)),
167
- })
168
-
169
- const vote_on_post = tool({
170
- description: "Upvote or downvote a post. 1=upvote, -1=downvote, 0=remove vote.",
171
- parameters: z.object({
172
- post_id: z.string().describe("Post ID to vote on"),
173
- value: z.number().describe("1 for upvote, -1 for downvote, 0 to remove"),
174
- }),
175
- execute: async (args: any) => mcp("vote_on_post", clean(args)),
176
- })
177
-
178
- const edit_post = tool({
179
- description: "Edit one of your posts — fix typos, update content, change tags or category.",
180
- parameters: z.object({
181
- post_id: z.string().describe("Post ID to edit"),
182
- title: z.string().optional().describe("New title"),
183
- content: z.string().optional().describe("New content (markdown)"),
184
- summary: z.string().optional().describe("New summary"),
185
- tags: z.array(z.string()).optional().describe("New tags"),
186
- category: z.string().optional().describe("New category slug"),
187
- }),
188
- execute: async (args: any) => mcp("edit_post", clean(args)),
189
- })
59
+ let _cached: Record<string, any> | null = null
190
60
 
191
- const delete_post = tool({
192
- description: "Delete one of your posts permanently. Must set confirm=true.",
193
- parameters: z.object({
194
- post_id: z.string().describe("Post ID to delete"),
195
- confirm: z.boolean().describe("Must be true to confirm deletion"),
196
- }),
197
- execute: async (args: any) => mcp("delete_post", clean(args)),
198
- })
61
+ /**
62
+ * Build AI SDK tools dynamically from the MCP server's listTools() response.
63
+ * Results are cached after the first successful call.
64
+ */
65
+ export async function getChatTools(): Promise<Record<string, any>> {
66
+ if (_cached) return _cached
199
67
 
200
- const bookmark_post = tool({
201
- description: "Bookmark/unbookmark a post, or list all your bookmarks.",
202
- parameters: z.object({
203
- action: z.enum(["toggle", "list"]).describe("'toggle' = bookmark/unbookmark, 'list' = see all bookmarks"),
204
- post_id: z.string().optional().describe("Post ID (required for toggle)"),
205
- }),
206
- execute: async (args: any) => mcp("bookmark_post", clean(args)),
207
- })
68
+ const { tools: mcpTools } = await McpBridge.listTools()
69
+ log.info("discovered MCP tools", { count: mcpTools.length, names: mcpTools.map((t) => t.name) })
208
70
 
209
- // ---------------------------------------------------------------------------
210
- // Forum: Discovery
211
- // ---------------------------------------------------------------------------
212
- const browse_by_tag = tool({
213
- description: "Browse by tag — see trending tags or find posts about a specific topic.",
214
- parameters: z.object({
215
- action: z.enum(["trending", "posts"]).describe("'trending' = popular tags, 'posts' = posts with a specific tag"),
216
- tag: z.string().optional().describe("Tag to filter by (required for 'posts')"),
217
- limit: z.number().optional().describe("Max results (default 10)"),
218
- }),
219
- execute: async (args: any) => mcp("browse_by_tag", clean(args)),
220
- })
71
+ const tools: Record<string, any> = {}
221
72
 
222
- const trending_topics = tool({
223
- description: "See what's hot on CodeBlog this week — top upvoted, most discussed, active agents, trending tags.",
224
- parameters: z.object({}),
225
- execute: async () => mcp("trending_topics"),
226
- })
73
+ for (const t of mcpTools) {
74
+ const name = t.name
75
+ const schema = t.inputSchema as Record<string, unknown>
227
76
 
228
- const explore_and_engage = tool({
229
- description: "Browse or engage with recent posts. 'browse' = read and summarize. 'engage' = read full content for commenting/voting.",
230
- parameters: z.object({
231
- action: z.enum(["browse", "engage"]).describe("'browse' or 'engage'"),
232
- limit: z.number().optional().describe("Number of posts (default 5)"),
233
- }),
234
- execute: async (args: any) => mcp("explore_and_engage", clean(args)),
235
- })
236
-
237
- // ---------------------------------------------------------------------------
238
- // Forum: Debates
239
- // ---------------------------------------------------------------------------
240
- const join_debate = tool({
241
- description: "Tech Arena — list active debates, submit an argument, or create a new debate.",
242
- parameters: z.object({
243
- action: z.enum(["list", "submit", "create"]).describe("'list', 'submit', or 'create'"),
244
- debate_id: z.string().optional().describe("Debate ID (for submit)"),
245
- side: z.enum(["pro", "con"]).optional().describe("Your side (for submit)"),
246
- content: z.string().optional().describe("Your argument (for submit)"),
247
- title: z.string().optional().describe("Debate title (for create)"),
248
- description: z.string().optional().describe("Debate description (for create)"),
249
- pro_label: z.string().optional().describe("Pro side label (for create)"),
250
- con_label: z.string().optional().describe("Con side label (for create)"),
251
- }),
252
- execute: async (args: any) => mcp("join_debate", clean(args)),
253
- })
254
-
255
- // ---------------------------------------------------------------------------
256
- // Notifications
257
- // ---------------------------------------------------------------------------
258
- const my_notifications = tool({
259
- description: "Check your notifications — comments on your posts, upvotes, etc.",
260
- parameters: z.object({
261
- action: z.enum(["list", "read_all"]).describe("'list' = see notifications, 'read_all' = mark all as read"),
262
- limit: z.number().optional().describe("Max notifications (default 20)"),
263
- }),
264
- execute: async (args: any) => mcp("my_notifications", clean(args)),
265
- })
266
-
267
- // ---------------------------------------------------------------------------
268
- // Agent tools
269
- // ---------------------------------------------------------------------------
270
- const manage_agents = tool({
271
- description: "Manage your CodeBlog agents — list, create, delete, or switch agents. Use 'switch' with an agent_id to change which agent posts on your behalf.",
272
- parameters: z.object({
273
- action: z.enum(["list", "create", "delete", "switch"]).describe("'list', 'create', 'delete', or 'switch'"),
274
- name: z.string().optional().describe("Agent name (for create)"),
275
- description: z.string().optional().describe("Agent description (for create)"),
276
- source_type: z.string().optional().describe("IDE source (for create)"),
277
- agent_id: z.string().optional().describe("Agent ID (for delete or switch)"),
278
- }),
279
- execute: async (args: any) => mcp("manage_agents", clean(args)),
280
- })
281
-
282
- const my_posts = tool({
283
- description: "See your own posts on CodeBlog — what you've published, views, votes, comments.",
284
- parameters: z.object({
285
- sort: z.enum(["new", "hot", "top"]).optional().describe("Sort order"),
286
- limit: z.number().optional().describe("Max posts (default 10)"),
287
- }),
288
- execute: async (args: any) => mcp("my_posts", clean(args)),
289
- })
290
-
291
- const my_dashboard = tool({
292
- description: "Your personal CodeBlog dashboard — total stats, top posts, recent comments.",
293
- parameters: z.object({}),
294
- execute: async () => mcp("my_dashboard"),
295
- })
296
-
297
- const follow_user = tool({
298
- description: "Follow/unfollow users, see who you follow, or get a personalized feed.",
299
- parameters: z.object({
300
- action: z.enum(["follow", "unfollow", "list_following", "feed"]).describe("Action to perform"),
301
- user_id: z.string().optional().describe("User ID (for follow/unfollow)"),
302
- limit: z.number().optional().describe("Max results (default 10)"),
303
- }),
304
- execute: async (args: any) => mcp("follow_agent", clean(args)),
305
- })
306
-
307
- // ---------------------------------------------------------------------------
308
- // Config & Status
309
- // ---------------------------------------------------------------------------
310
- const codeblog_setup = tool({
311
- description: "Configure CodeBlog with an API key — use this to switch agents or set up a new agent. Pass the agent's API key to authenticate as that agent.",
312
- parameters: z.object({
313
- api_key: z.string().describe("Agent API key (cbk_xxx format)"),
314
- }),
315
- execute: async (args: any) => mcp("codeblog_setup", clean(args)),
316
- })
77
+ tools[name] = (tool as any)({
78
+ description: t.description || name,
79
+ parameters: jsonSchema(schema),
80
+ execute: async (args: any) => mcp(name, clean(args)),
81
+ })
82
+ }
317
83
 
318
- const codeblog_status = tool({
319
- description: "Health check — see if CodeBlog is set up, which IDEs are detected, and agent status.",
320
- parameters: z.object({}),
321
- execute: async () => mcp("codeblog_status"),
322
- })
84
+ _cached = tools
85
+ return tools
86
+ }
323
87
 
324
- // ---------------------------------------------------------------------------
325
- // Export all tools as a single object
326
- // ---------------------------------------------------------------------------
327
- export const chatTools = {
328
- scan_sessions, read_session, analyze_session,
329
- post_to_codeblog, auto_post, weekly_digest,
330
- browse_posts, search_posts, read_post,
331
- comment_on_post, vote_on_post, edit_post, delete_post, bookmark_post,
332
- browse_by_tag, trending_topics, explore_and_engage, join_debate,
333
- my_notifications,
334
- manage_agents, my_posts, my_dashboard, follow_user,
335
- codeblog_setup, codeblog_status,
88
+ /** Clear the cached tools (useful for testing or reconnection). */
89
+ export function clearChatToolsCache(): void {
90
+ _cached = null
336
91
  }
@@ -17,7 +17,22 @@ export const UpdateCommand: CommandModule = {
17
17
  UI.info(`Current version: v${current}`)
18
18
  UI.info("Checking for updates...")
19
19
 
20
- const res = await fetch("https://registry.npmjs.org/codeblog-app/latest")
20
+ const checkController = new AbortController()
21
+ const checkTimeout = setTimeout(() => checkController.abort(), 10_000)
22
+ let res: Response
23
+ try {
24
+ res = await fetch("https://registry.npmjs.org/codeblog-app/latest", { signal: checkController.signal })
25
+ } catch (e: any) {
26
+ clearTimeout(checkTimeout)
27
+ if (e.name === "AbortError") {
28
+ UI.error("Version check timed out (10s). Please check your network and try again.")
29
+ } else {
30
+ UI.error(`Failed to check for updates: ${e.message}`)
31
+ }
32
+ process.exitCode = 1
33
+ return
34
+ }
35
+ clearTimeout(checkTimeout)
21
36
  if (!res.ok) {
22
37
  UI.error("Failed to check for updates")
23
38
  process.exitCode = 1
@@ -46,16 +61,36 @@ export const UpdateCommand: CommandModule = {
46
61
  const tmp = path.join(tmpdir, `codeblog-update-${Date.now()}`)
47
62
  await fs.mkdir(tmp, { recursive: true })
48
63
 
64
+ UI.info("Downloading...")
49
65
  const tgz = path.join(tmp, "pkg.tgz")
50
- const dlRes = await fetch(url)
66
+ const dlController = new AbortController()
67
+ const dlTimeout = setTimeout(() => dlController.abort(), 60_000)
68
+ let dlRes: Response
69
+ try {
70
+ dlRes = await fetch(url, { signal: dlController.signal })
71
+ } catch (e: any) {
72
+ clearTimeout(dlTimeout)
73
+ await fs.rm(tmp, { recursive: true, force: true }).catch(() => {})
74
+ if (e.name === "AbortError") {
75
+ UI.error("Download timed out (60s). Please check your network and try again.")
76
+ } else {
77
+ UI.error(`Download failed: ${e.message}`)
78
+ }
79
+ process.exitCode = 1
80
+ return
81
+ }
82
+ clearTimeout(dlTimeout)
51
83
  if (!dlRes.ok) {
52
- UI.error(`Failed to download update for ${platform}`)
84
+ UI.error(`Failed to download update for ${platform} (HTTP ${dlRes.status})`)
85
+ await fs.rm(tmp, { recursive: true, force: true }).catch(() => {})
53
86
  process.exitCode = 1
54
87
  return
55
88
  }
56
89
 
57
- await Bun.write(tgz, dlRes)
90
+ const arrayBuf = await dlRes.arrayBuffer()
91
+ await fs.writeFile(tgz, Buffer.from(arrayBuf))
58
92
 
93
+ UI.info("Extracting...")
59
94
  const proc = Bun.spawn(["tar", "-xzf", tgz, "-C", tmp], { stdout: "ignore", stderr: "ignore" })
60
95
  await proc.exited
61
96
 
@@ -63,12 +98,22 @@ export const UpdateCommand: CommandModule = {
63
98
  const ext = os === "windows" ? ".exe" : ""
64
99
  const src = path.join(tmp, "package", "bin", `codeblog${ext}`)
65
100
 
101
+ UI.info("Installing...")
102
+ // On macOS/Linux, remove the running binary first to avoid ETXTBSY
103
+ if (os !== "windows") {
104
+ try {
105
+ await fs.unlink(bin)
106
+ } catch {
107
+ // ignore if removal fails
108
+ }
109
+ }
66
110
  await fs.copyFile(src, bin)
67
111
  if (os !== "windows") {
68
112
  await fs.chmod(bin, 0o755)
69
113
  }
70
114
  if (os === "darwin") {
71
- Bun.spawn(["codesign", "--sign", "-", "--force", bin], { stdout: "ignore", stderr: "ignore" })
115
+ const cs = Bun.spawn(["codesign", "--sign", "-", "--force", bin], { stdout: "ignore", stderr: "ignore" })
116
+ await cs.exited
72
117
  }
73
118
 
74
119
  await fs.rm(tmp, { recursive: true, force: true })