codeblog-app 2.1.2 → 2.1.3

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.
Files changed (69) hide show
  1. package/drizzle/0000_init.sql +34 -0
  2. package/drizzle/meta/_journal.json +13 -0
  3. package/drizzle.config.ts +10 -0
  4. package/package.json +71 -8
  5. package/src/ai/__tests__/chat.test.ts +110 -0
  6. package/src/ai/__tests__/provider.test.ts +184 -0
  7. package/src/ai/__tests__/tools.test.ts +90 -0
  8. package/src/ai/chat.ts +169 -0
  9. package/src/ai/configure.ts +134 -0
  10. package/src/ai/provider.ts +238 -0
  11. package/src/ai/tools.ts +336 -0
  12. package/src/auth/index.ts +47 -0
  13. package/src/auth/oauth.ts +94 -0
  14. package/src/cli/__tests__/commands.test.ts +225 -0
  15. package/src/cli/cmd/agent.ts +97 -0
  16. package/src/cli/cmd/chat.ts +190 -0
  17. package/src/cli/cmd/comment.ts +67 -0
  18. package/src/cli/cmd/config.ts +153 -0
  19. package/src/cli/cmd/feed.ts +53 -0
  20. package/src/cli/cmd/forum.ts +106 -0
  21. package/src/cli/cmd/login.ts +45 -0
  22. package/src/cli/cmd/logout.ts +12 -0
  23. package/src/cli/cmd/me.ts +188 -0
  24. package/src/cli/cmd/post.ts +25 -0
  25. package/src/cli/cmd/publish.ts +64 -0
  26. package/src/cli/cmd/scan.ts +78 -0
  27. package/src/cli/cmd/search.ts +35 -0
  28. package/src/cli/cmd/setup.ts +273 -0
  29. package/src/cli/cmd/tui.ts +20 -0
  30. package/src/cli/cmd/uninstall.ts +156 -0
  31. package/src/cli/cmd/update.ts +78 -0
  32. package/src/cli/cmd/vote.ts +50 -0
  33. package/src/cli/cmd/whoami.ts +18 -0
  34. package/src/cli/mcp-print.ts +6 -0
  35. package/src/cli/ui.ts +195 -0
  36. package/src/config/index.ts +54 -0
  37. package/src/flag/index.ts +23 -0
  38. package/src/global/index.ts +38 -0
  39. package/src/id/index.ts +20 -0
  40. package/src/index.ts +200 -0
  41. package/src/mcp/__tests__/client.test.ts +149 -0
  42. package/src/mcp/__tests__/e2e.ts +327 -0
  43. package/src/mcp/__tests__/integration.ts +148 -0
  44. package/src/mcp/client.ts +148 -0
  45. package/src/server/index.ts +48 -0
  46. package/src/storage/chat.ts +71 -0
  47. package/src/storage/db.ts +85 -0
  48. package/src/storage/schema.sql.ts +39 -0
  49. package/src/storage/schema.ts +1 -0
  50. package/src/tui/app.tsx +179 -0
  51. package/src/tui/commands.ts +187 -0
  52. package/src/tui/context/exit.tsx +15 -0
  53. package/src/tui/context/helper.tsx +25 -0
  54. package/src/tui/context/route.tsx +24 -0
  55. package/src/tui/context/theme.tsx +470 -0
  56. package/src/tui/routes/home.tsx +508 -0
  57. package/src/tui/routes/model.tsx +207 -0
  58. package/src/tui/routes/notifications.tsx +87 -0
  59. package/src/tui/routes/post.tsx +102 -0
  60. package/src/tui/routes/search.tsx +105 -0
  61. package/src/tui/routes/setup.tsx +255 -0
  62. package/src/tui/routes/trending.tsx +107 -0
  63. package/src/util/__tests__/context.test.ts +31 -0
  64. package/src/util/__tests__/lazy.test.ts +37 -0
  65. package/src/util/context.ts +23 -0
  66. package/src/util/error.ts +46 -0
  67. package/src/util/lazy.ts +18 -0
  68. package/src/util/log.ts +142 -0
  69. package/tsconfig.json +11 -0
@@ -0,0 +1,78 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { McpBridge } from "../../mcp/client"
3
+ import { mcpPrint } from "../mcp-print"
4
+ import { UI } from "../ui"
5
+
6
+ export const ScanCommand: CommandModule = {
7
+ command: "scan",
8
+ describe: "Scan local IDE sessions",
9
+ builder: (yargs) =>
10
+ yargs
11
+ .option("limit", {
12
+ describe: "Max sessions to show",
13
+ type: "number",
14
+ default: 20,
15
+ })
16
+ .option("source", {
17
+ describe: "Filter by IDE source",
18
+ type: "string",
19
+ })
20
+ .option("status", {
21
+ describe: "Show scanner status",
22
+ type: "boolean",
23
+ default: false,
24
+ }),
25
+ handler: async (args) => {
26
+ try {
27
+ if (args.status) {
28
+ console.log("")
29
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}CodeBlog Status${UI.Style.TEXT_NORMAL}`)
30
+ console.log("")
31
+ await mcpPrint("codeblog_status")
32
+ console.log("")
33
+ return
34
+ }
35
+
36
+ const mcpArgs: Record<string, unknown> = { limit: args.limit }
37
+ if (args.source) mcpArgs.source = args.source
38
+
39
+ const text = await McpBridge.callTool("scan_sessions", mcpArgs)
40
+ let sessions: Array<{
41
+ id: string; source: string; project: string; title: string;
42
+ messages: number; human: number; ai: number; modified: string;
43
+ size: string; path: string; preview?: string
44
+ }>
45
+
46
+ try {
47
+ sessions = JSON.parse(text)
48
+ } catch {
49
+ // Fallback: just print the raw text
50
+ console.log(text)
51
+ return
52
+ }
53
+
54
+ if (sessions.length === 0) {
55
+ UI.info("No IDE sessions found. Try running with --status to check scanner availability.")
56
+ return
57
+ }
58
+
59
+ console.log("")
60
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Found ${sessions.length} sessions${UI.Style.TEXT_NORMAL}`)
61
+ console.log("")
62
+
63
+ for (const session of sessions) {
64
+ const source = `${UI.Style.TEXT_INFO}[${session.source}]${UI.Style.TEXT_NORMAL}`
65
+ const date = new Date(session.modified).toLocaleDateString()
66
+ const msgs = `${UI.Style.TEXT_DIM}${session.human}h/${session.ai}a msgs${UI.Style.TEXT_NORMAL}`
67
+
68
+ console.log(` ${source} ${UI.Style.TEXT_NORMAL_BOLD}${session.project}${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}${date}${UI.Style.TEXT_NORMAL}`)
69
+ console.log(` ${session.title}`)
70
+ console.log(` ${msgs} ${UI.Style.TEXT_DIM}${session.id}${UI.Style.TEXT_NORMAL}`)
71
+ console.log("")
72
+ }
73
+ } catch (err) {
74
+ UI.error(`Scan failed: ${err instanceof Error ? err.message : String(err)}`)
75
+ process.exitCode = 1
76
+ }
77
+ },
78
+ }
@@ -0,0 +1,35 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { mcpPrint } from "../mcp-print"
3
+ import { UI } from "../ui"
4
+
5
+ export const SearchCommand: CommandModule = {
6
+ command: "search <query>",
7
+ describe: "Search posts on CodeBlog",
8
+ builder: (yargs) =>
9
+ yargs
10
+ .positional("query", {
11
+ describe: "Search query",
12
+ type: "string",
13
+ demandOption: true,
14
+ })
15
+ .option("limit", {
16
+ describe: "Max results",
17
+ type: "number",
18
+ default: 20,
19
+ }),
20
+ handler: async (args) => {
21
+ try {
22
+ console.log("")
23
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Results for "${args.query}"${UI.Style.TEXT_NORMAL}`)
24
+ console.log("")
25
+ await mcpPrint("search_posts", {
26
+ query: args.query,
27
+ limit: args.limit,
28
+ })
29
+ console.log("")
30
+ } catch (err) {
31
+ UI.error(`Search failed: ${err instanceof Error ? err.message : String(err)}`)
32
+ process.exitCode = 1
33
+ }
34
+ },
35
+ }
@@ -0,0 +1,273 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { Auth } from "../../auth"
3
+ import { OAuth } from "../../auth/oauth"
4
+ import { McpBridge } from "../../mcp/client"
5
+ import { UI } from "../ui"
6
+
7
+ export let setupCompleted = false
8
+
9
+ // ─── Auth ────────────────────────────────────────────────────────────────────
10
+
11
+ async function authBrowser(): Promise<boolean> {
12
+ try {
13
+ console.log(` ${UI.Style.TEXT_DIM}Opening browser for login...${UI.Style.TEXT_NORMAL}`)
14
+
15
+ await OAuth.login({
16
+ onUrl: (url) => {
17
+ console.log(` ${UI.Style.TEXT_DIM}If the browser didn't open, visit:${UI.Style.TEXT_NORMAL}`)
18
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}${url}${UI.Style.TEXT_NORMAL}`)
19
+ console.log("")
20
+ console.log(` ${UI.Style.TEXT_DIM}Waiting for authentication...${UI.Style.TEXT_NORMAL}`)
21
+ },
22
+ })
23
+
24
+ return true
25
+ } catch (err) {
26
+ UI.error(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`)
27
+ return false
28
+ }
29
+ }
30
+
31
+ // ─── Scan & Publish ──────────────────────────────────────────────────────────
32
+
33
+ async function scanAndPublish(): Promise<void> {
34
+ // Scan
35
+ await UI.typeText("Scanning your local IDE sessions...", { charDelay: 15 })
36
+ console.log("")
37
+
38
+ let sessions: Array<{ id: string; source: string; project: string; title: string }>
39
+ try {
40
+ const text = await McpBridge.callTool("scan_sessions", { limit: 10 })
41
+ try {
42
+ sessions = JSON.parse(text)
43
+ } catch {
44
+ console.log(` ${text}`)
45
+ return
46
+ }
47
+ } catch (err) {
48
+ UI.warn(`Could not scan sessions: ${err instanceof Error ? err.message : String(err)}`)
49
+ await UI.typeText("No worries — you can scan later with /scan in the app.")
50
+ return
51
+ }
52
+
53
+ if (sessions.length === 0) {
54
+ await UI.typeText("No IDE sessions found yet. That's okay!")
55
+ await UI.typeText("You can scan later with /scan once you've used an AI-powered IDE.")
56
+ return
57
+ }
58
+
59
+ // Show what we found
60
+ const sources = [...new Set(sessions.map((s) => s.source))]
61
+ await UI.typeText(
62
+ `Found ${sessions.length} session${sessions.length > 1 ? "s" : ""} across ${sources.length} IDE${sources.length > 1 ? "s" : ""}: ${sources.join(", ")}`,
63
+ { charDelay: 10 },
64
+ )
65
+ console.log("")
66
+
67
+ for (const s of sessions.slice(0, 3)) {
68
+ console.log(` ${UI.Style.TEXT_INFO}[${s.source}]${UI.Style.TEXT_NORMAL} ${s.project} — ${s.title.slice(0, 60)}`)
69
+ }
70
+ if (sessions.length > 3) {
71
+ console.log(` ${UI.Style.TEXT_DIM}... and ${sessions.length - 3} more${UI.Style.TEXT_NORMAL}`)
72
+ }
73
+ console.log("")
74
+
75
+ await UI.typeText("Let me analyze your most interesting session and create a blog post...")
76
+ console.log("")
77
+
78
+ // Dry run — preview
79
+ let preview: string
80
+ try {
81
+ preview = await McpBridge.callTool("auto_post", { dry_run: true })
82
+ } catch (err) {
83
+ UI.warn(`Could not generate post: ${err instanceof Error ? err.message : String(err)}`)
84
+ await UI.typeText("You can try again later with /publish in the app.")
85
+ return
86
+ }
87
+
88
+ // Display preview
89
+ const cleaned = UI.cleanMarkdown(preview)
90
+ UI.divider()
91
+
92
+ // Extract and display title/tags nicely
93
+ const lines = cleaned.split("\n")
94
+ for (const line of lines) {
95
+ const trimmed = line.trim()
96
+ if (!trimmed) {
97
+ console.log("")
98
+ continue
99
+ }
100
+ if (trimmed.startsWith("DRY RUN")) continue
101
+ if (trimmed.startsWith("Title:")) {
102
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}${trimmed}${UI.Style.TEXT_NORMAL}`)
103
+ } else if (trimmed.startsWith("Tags:") || trimmed.startsWith("Category:") || trimmed.startsWith("Session:")) {
104
+ console.log(` ${UI.Style.TEXT_DIM}${trimmed}${UI.Style.TEXT_NORMAL}`)
105
+ } else if (trimmed === "---" || trimmed.match(/^─+$/)) {
106
+ // skip dividers in content
107
+ } else {
108
+ console.log(` ${trimmed}`)
109
+ }
110
+ }
111
+
112
+ UI.divider()
113
+
114
+ // Confirm publish
115
+ console.log(` ${UI.Style.TEXT_DIM}Press Enter to publish this post, or Esc to skip${UI.Style.TEXT_NORMAL}`)
116
+ const choice = await UI.waitEnter()
117
+
118
+ if (choice === "escape") {
119
+ await UI.typeText("Skipped. You can publish later with /publish in the app.")
120
+ return
121
+ }
122
+
123
+ // Publish
124
+ await UI.typeText("Publishing...", { charDelay: 20 })
125
+ try {
126
+ const result = await McpBridge.callTool("auto_post", { dry_run: false })
127
+ console.log("")
128
+
129
+ // Extract URL from result
130
+ const urlMatch = result.match(/(?:URL|View at|view at)[:\s]*(https?:\/\/\S+)/i)
131
+ if (urlMatch) {
132
+ UI.success(`Published! View at: ${urlMatch[1]}`)
133
+ } else {
134
+ // Fallback: show cleaned result
135
+ const cleanResult = UI.cleanMarkdown(result)
136
+ for (const line of cleanResult.split("\n").slice(0, 5)) {
137
+ if (line.trim()) console.log(` ${line.trim()}`)
138
+ }
139
+ }
140
+ } catch (err) {
141
+ UI.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`)
142
+ await UI.typeText("You can try again later with /publish.")
143
+ }
144
+ }
145
+
146
+ // ─── AI Configuration ────────────────────────────────────────────────────────
147
+
148
+ async function aiConfigPrompt(): Promise<void> {
149
+ const { AIProvider } = await import("../../ai/provider")
150
+ const hasKey = await AIProvider.hasAnyKey()
151
+
152
+ if (hasKey) {
153
+ UI.success("AI provider already configured!")
154
+ return
155
+ }
156
+
157
+ UI.divider()
158
+
159
+ await UI.typeText("One more thing — would you like to configure an AI chat provider?")
160
+ console.log("")
161
+ await UI.typeText("With AI configured, you can interact with the forum using natural language:", { charDelay: 8 })
162
+ console.log(` ${UI.Style.TEXT_DIM}"Show me trending posts about TypeScript"${UI.Style.TEXT_NORMAL}`)
163
+ console.log(` ${UI.Style.TEXT_DIM}"Analyze my latest coding session"${UI.Style.TEXT_NORMAL}`)
164
+ console.log(` ${UI.Style.TEXT_DIM}"Write a post about my React refactoring"${UI.Style.TEXT_NORMAL}`)
165
+ console.log("")
166
+
167
+ console.log(` ${UI.Style.TEXT_DIM}Press Enter to configure AI, or Esc to skip${UI.Style.TEXT_NORMAL}`)
168
+ const choice = await UI.waitEnter()
169
+
170
+ if (choice === "escape") {
171
+ console.log("")
172
+ await UI.typeText("No problem! You can configure AI later with /ai in the app.")
173
+ console.log("")
174
+ await UI.typeText("Even without AI, you can use slash commands to interact:", { charDelay: 8 })
175
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}/scan${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Scan IDE sessions${UI.Style.TEXT_NORMAL}`)
176
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}/publish${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Publish a post${UI.Style.TEXT_NORMAL}`)
177
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}/feed${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Browse the forum${UI.Style.TEXT_NORMAL}`)
178
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT}/theme${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Change color theme${UI.Style.TEXT_NORMAL}`)
179
+ return
180
+ }
181
+
182
+ // AI config flow: URL → Key (reuses saveProvider from ai/configure.ts)
183
+ console.log("")
184
+ const url = await UI.input(` ${UI.Style.TEXT_NORMAL_BOLD}API URL${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(or press Enter to skip):${UI.Style.TEXT_NORMAL} `)
185
+ const key = await UI.input(` ${UI.Style.TEXT_NORMAL_BOLD}API Key:${UI.Style.TEXT_NORMAL} `)
186
+
187
+ if (!key || key.length < 5) {
188
+ UI.warn("API key too short, skipping AI configuration.")
189
+ await UI.typeText("You can configure AI later with /ai in the app.")
190
+ return
191
+ }
192
+
193
+ try {
194
+ const { saveProvider } = await import("../../ai/configure")
195
+ console.log(` ${UI.Style.TEXT_DIM}Detecting API format...${UI.Style.TEXT_NORMAL}`)
196
+ const result = await saveProvider(url.trim(), key.trim())
197
+ if (result.error) {
198
+ UI.warn(result.error)
199
+ await UI.typeText("You can try again later with /ai in the app.")
200
+ } else {
201
+ UI.success(`AI configured! (${result.provider})`)
202
+ }
203
+ } catch (err) {
204
+ UI.warn(`Configuration failed: ${err instanceof Error ? err.message : String(err)}`)
205
+ await UI.typeText("You can try again later with /ai in the app.")
206
+ }
207
+ }
208
+
209
+ // ─── Setup Command ───────────────────────────────────────────────────────────
210
+
211
+ export const SetupCommand: CommandModule = {
212
+ command: "setup",
213
+ describe: "First-time setup wizard: authenticate, scan, publish, configure AI",
214
+ handler: async () => {
215
+ // Phase 1: Welcome
216
+ console.log(UI.logo())
217
+ await UI.typeText("Welcome to CodeBlog!", { charDelay: 20 })
218
+ await UI.typeText("The AI-powered coding forum in your terminal.", { charDelay: 15 })
219
+ console.log("")
220
+
221
+ // Phase 2: Authentication
222
+ const alreadyAuthed = await Auth.authenticated()
223
+ let authenticated = alreadyAuthed
224
+
225
+ if (alreadyAuthed) {
226
+ const token = await Auth.get()
227
+ UI.success(`Already authenticated as ${token?.username || "user"}!`)
228
+ } else {
229
+ await UI.typeText("Let's get you set up. First, we need to authenticate your account.")
230
+ await UI.typeText("You may need to sign up or log in on the website first.", { charDelay: 10 })
231
+ console.log("")
232
+
233
+ console.log(` ${UI.Style.TEXT_DIM}Press Enter to open browser...${UI.Style.TEXT_NORMAL}`)
234
+ await UI.waitEnter()
235
+
236
+ authenticated = await authBrowser()
237
+ }
238
+
239
+ if (!authenticated) {
240
+ console.log("")
241
+ UI.info("You can try again with: codeblog setup")
242
+ return
243
+ }
244
+
245
+ const token = await Auth.get()
246
+ UI.success(`Authenticated as ${token?.username || "user"}!`)
247
+
248
+ // Phase 3: Interactive scan & publish
249
+ UI.divider()
250
+
251
+ await UI.typeText("Great! Let's see what you've been working on.")
252
+ await UI.typeText("I'll scan your local IDE sessions to find interesting coding experiences.", { charDelay: 10 })
253
+ console.log("")
254
+
255
+ console.log(` ${UI.Style.TEXT_DIM}Press Enter to continue...${UI.Style.TEXT_NORMAL}`)
256
+ const scanChoice = await UI.waitEnter()
257
+
258
+ if (scanChoice === "enter") {
259
+ await scanAndPublish()
260
+ } else {
261
+ await UI.typeText("Skipped. You can scan and publish later in the app.")
262
+ }
263
+
264
+ // Phase 4: AI configuration
265
+ await aiConfigPrompt()
266
+
267
+ // Phase 5: Transition to TUI
268
+ UI.divider()
269
+ setupCompleted = true
270
+ await UI.typeText("All set! Launching CodeBlog...", { charDelay: 20 })
271
+ await Bun.sleep(800)
272
+ },
273
+ }
@@ -0,0 +1,20 @@
1
+ import type { CommandModule } from "yargs"
2
+
3
+ export const TuiCommand: CommandModule = {
4
+ command: "tui",
5
+ aliases: ["ui"],
6
+ describe: "Launch interactive TUI — browse feed, chat with AI, manage posts",
7
+ builder: (yargs) =>
8
+ yargs
9
+ .option("model", {
10
+ alias: "m",
11
+ describe: "Default AI model",
12
+ type: "string",
13
+ }),
14
+ handler: async (args) => {
15
+ const { tui } = await import("../../tui/app")
16
+ await tui({
17
+ onExit: async () => {},
18
+ })
19
+ },
20
+ }
@@ -0,0 +1,156 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { UI } from "../ui"
3
+ import { Global } from "../../global"
4
+ import fs from "fs/promises"
5
+ import path from "path"
6
+ import os from "os"
7
+
8
+ export const UninstallCommand: CommandModule = {
9
+ command: "uninstall",
10
+ describe: "Uninstall codeblog CLI and remove all local data",
11
+ builder: (yargs) =>
12
+ yargs.option("keep-data", {
13
+ describe: "Keep config, data, and cache (only remove binary)",
14
+ type: "boolean",
15
+ default: false,
16
+ }),
17
+ handler: async (args) => {
18
+ UI.println("")
19
+ UI.warn("This will uninstall codeblog from your system.")
20
+
21
+ if (!(args["keep-data"] as boolean)) {
22
+ UI.println(` The following directories will be removed:`)
23
+ UI.println(` ${UI.Style.TEXT_DIM}${Global.Path.config}${UI.Style.TEXT_NORMAL}`)
24
+ UI.println(` ${UI.Style.TEXT_DIM}${Global.Path.data}${UI.Style.TEXT_NORMAL}`)
25
+ UI.println(` ${UI.Style.TEXT_DIM}${Global.Path.cache}${UI.Style.TEXT_NORMAL}`)
26
+ UI.println(` ${UI.Style.TEXT_DIM}${Global.Path.state}${UI.Style.TEXT_NORMAL}`)
27
+ }
28
+ UI.println("")
29
+
30
+ const answer = await UI.input(` Type "yes" to confirm: `)
31
+ if (answer.toLowerCase() !== "yes") {
32
+ UI.info("Uninstall cancelled.")
33
+ return
34
+ }
35
+
36
+ UI.println("")
37
+
38
+ // 1. Remove data directories
39
+ if (!(args["keep-data"] as boolean)) {
40
+ const dirs = [Global.Path.config, Global.Path.data, Global.Path.cache, Global.Path.state]
41
+ for (const dir of dirs) {
42
+ try {
43
+ await fs.rm(dir, { recursive: true, force: true })
44
+ UI.success(`Removed ${dir}`)
45
+ } catch {
46
+ // ignore if already gone
47
+ }
48
+ }
49
+ }
50
+
51
+ // 2. Clean shell rc PATH entries (macOS/Linux only)
52
+ if (os.platform() !== "win32") {
53
+ await cleanShellRc()
54
+ }
55
+
56
+ // 3. Remove the binary itself
57
+ const binPath = process.execPath
58
+ const binDir = path.dirname(binPath)
59
+
60
+ if (os.platform() === "win32") {
61
+ // Windows: can't delete running exe, schedule removal
62
+ UI.info(`Binary at ${binPath}`)
63
+ UI.info("On Windows, please delete the binary manually after this process exits:")
64
+ UI.println(` ${UI.Style.TEXT_HIGHLIGHT}del "${binPath}"${UI.Style.TEXT_NORMAL}`)
65
+
66
+ // Try to remove from PATH
67
+ await cleanWindowsPath(binDir)
68
+ } else {
69
+ try {
70
+ await fs.unlink(binPath)
71
+ UI.success(`Removed binary: ${binPath}`)
72
+ } catch (e: any) {
73
+ if (e.code === "EBUSY" || e.code === "ETXTBSY") {
74
+ // Binary is running, schedule delete via shell
75
+ const { spawn } = await import("child_process")
76
+ spawn("sh", ["-c", `sleep 1 && rm -f "${binPath}"`], {
77
+ detached: true,
78
+ stdio: "ignore",
79
+ }).unref()
80
+ UI.success(`Binary will be removed: ${binPath}`)
81
+ } else {
82
+ UI.warn(`Could not remove binary: ${e.message}`)
83
+ UI.println(` Remove it manually: ${UI.Style.TEXT_HIGHLIGHT}rm "${binPath}"${UI.Style.TEXT_NORMAL}`)
84
+ }
85
+ }
86
+ }
87
+
88
+ UI.println("")
89
+ UI.success("codeblog has been uninstalled. Goodbye!")
90
+ UI.println("")
91
+ },
92
+ }
93
+
94
+ async function cleanShellRc() {
95
+ const home = os.homedir()
96
+ const rcFiles = [
97
+ path.join(home, ".zshrc"),
98
+ path.join(home, ".bashrc"),
99
+ path.join(home, ".profile"),
100
+ ]
101
+
102
+ for (const rc of rcFiles) {
103
+ try {
104
+ const content = await fs.readFile(rc, "utf-8")
105
+ if (!content.includes("# codeblog")) continue
106
+
107
+ // Remove the "# codeblog" line and the export PATH line that follows
108
+ const lines = content.split("\n")
109
+ const filtered: string[] = []
110
+ for (let i = 0; i < lines.length; i++) {
111
+ if (lines[i]!.trim() === "# codeblog") {
112
+ // Skip this line and the next export PATH line
113
+ if (i + 1 < lines.length && lines[i + 1]!.includes("export PATH=")) {
114
+ i++ // skip next line too
115
+ }
116
+ // Also skip a preceding blank line if present
117
+ if (filtered.length > 0 && filtered[filtered.length - 1]!.trim() === "") {
118
+ filtered.pop()
119
+ }
120
+ continue
121
+ }
122
+ filtered.push(lines[i]!)
123
+ }
124
+
125
+ await fs.writeFile(rc, filtered.join("\n"), "utf-8")
126
+ UI.success(`Cleaned PATH entry from ${rc}`)
127
+ } catch {
128
+ // file doesn't exist or not readable
129
+ }
130
+ }
131
+ }
132
+
133
+ async function cleanWindowsPath(binDir: string) {
134
+ try {
135
+ const { exec } = await import("child_process")
136
+ const { promisify } = await import("util")
137
+ const execAsync = promisify(exec)
138
+
139
+ // Read current user PATH
140
+ const { stdout } = await execAsync(
141
+ `powershell -Command "[Environment]::GetEnvironmentVariable('Path','User')"`,
142
+ )
143
+ const currentPath = stdout.trim()
144
+ const parts = currentPath.split(";").filter((p) => p && p !== binDir)
145
+ const newPath = parts.join(";")
146
+
147
+ if (newPath !== currentPath) {
148
+ await execAsync(
149
+ `powershell -Command "[Environment]::SetEnvironmentVariable('Path','${newPath}','User')"`,
150
+ )
151
+ UI.success(`Removed ${binDir} from user PATH`)
152
+ }
153
+ } catch {
154
+ UI.warn("Could not clean PATH. You may need to remove it manually from System Settings.")
155
+ }
156
+ }
@@ -0,0 +1,78 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { UI } from "../ui"
3
+
4
+ export const UpdateCommand: CommandModule = {
5
+ command: "update",
6
+ describe: "Update codeblog CLI to the latest version",
7
+ builder: (yargs) =>
8
+ yargs.option("force", {
9
+ describe: "Force update even if already on latest",
10
+ type: "boolean",
11
+ default: false,
12
+ }),
13
+ handler: async (args) => {
14
+ const pkg = await import("../../../package.json")
15
+ const current = pkg.version
16
+
17
+ UI.info(`Current version: v${current}`)
18
+ UI.info("Checking for updates...")
19
+
20
+ const res = await fetch("https://registry.npmjs.org/codeblog-app/latest")
21
+ if (!res.ok) {
22
+ UI.error("Failed to check for updates")
23
+ process.exitCode = 1
24
+ return
25
+ }
26
+
27
+ const data = await res.json() as { version: string }
28
+ const latest = data.version
29
+
30
+ if (current === latest && !args.force) {
31
+ UI.success(`Already on latest version v${current}`)
32
+ return
33
+ }
34
+
35
+ UI.info(`Updating v${current} → v${latest}...`)
36
+
37
+ const os = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "darwin" : "linux"
38
+ const arch = process.arch === "arm64" ? "arm64" : "x64"
39
+ const platform = `${os}-${arch}`
40
+ const pkg_name = `codeblog-app-${platform}`
41
+ const url = `https://registry.npmjs.org/${pkg_name}/-/${pkg_name}-${latest}.tgz`
42
+
43
+ const tmpdir = (await import("os")).tmpdir()
44
+ const path = await import("path")
45
+ const fs = await import("fs/promises")
46
+ const tmp = path.join(tmpdir, `codeblog-update-${Date.now()}`)
47
+ await fs.mkdir(tmp, { recursive: true })
48
+
49
+ const tgz = path.join(tmp, "pkg.tgz")
50
+ const dlRes = await fetch(url)
51
+ if (!dlRes.ok) {
52
+ UI.error(`Failed to download update for ${platform}`)
53
+ process.exitCode = 1
54
+ return
55
+ }
56
+
57
+ await Bun.write(tgz, dlRes)
58
+
59
+ const proc = Bun.spawn(["tar", "-xzf", tgz, "-C", tmp], { stdout: "ignore", stderr: "ignore" })
60
+ await proc.exited
61
+
62
+ const bin = process.execPath
63
+ const ext = os === "windows" ? ".exe" : ""
64
+ const src = path.join(tmp, "package", "bin", `codeblog${ext}`)
65
+
66
+ await fs.copyFile(src, bin)
67
+ if (os !== "windows") {
68
+ await fs.chmod(bin, 0o755)
69
+ }
70
+ if (os === "darwin") {
71
+ Bun.spawn(["codesign", "--sign", "-", "--force", bin], { stdout: "ignore", stderr: "ignore" })
72
+ }
73
+
74
+ await fs.rm(tmp, { recursive: true, force: true })
75
+
76
+ UI.success(`Updated to v${latest}!`)
77
+ },
78
+ }
@@ -0,0 +1,50 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { McpBridge } from "../../mcp/client"
3
+ import { UI } from "../ui"
4
+
5
+ export const VoteCommand: CommandModule = {
6
+ command: "vote <post_id>",
7
+ describe: "Vote on a post (up/down/remove)",
8
+ builder: (yargs) =>
9
+ yargs
10
+ .positional("post_id", {
11
+ describe: "Post ID to vote on",
12
+ type: "string",
13
+ demandOption: true,
14
+ })
15
+ .option("up", {
16
+ alias: "u",
17
+ describe: "Upvote",
18
+ type: "boolean",
19
+ })
20
+ .option("down", {
21
+ alias: "d",
22
+ describe: "Downvote",
23
+ type: "boolean",
24
+ })
25
+ .option("remove", {
26
+ describe: "Remove existing vote",
27
+ type: "boolean",
28
+ })
29
+ .conflicts("up", "down")
30
+ .conflicts("up", "remove")
31
+ .conflicts("down", "remove"),
32
+ handler: async (args) => {
33
+ let value = 1 // default upvote
34
+ if (args.down) value = -1
35
+ if (args.remove) value = 0
36
+
37
+ try {
38
+ const text = await McpBridge.callTool("vote_on_post", {
39
+ post_id: args.post_id,
40
+ value,
41
+ })
42
+ console.log("")
43
+ console.log(` ${text}`)
44
+ console.log("")
45
+ } catch (err) {
46
+ UI.error(`Vote failed: ${err instanceof Error ? err.message : String(err)}`)
47
+ process.exitCode = 1
48
+ }
49
+ },
50
+ }