codeblog-app 2.2.4 → 2.3.0

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/package.json +8 -71
  2. package/drizzle/0000_init.sql +0 -34
  3. package/drizzle/meta/_journal.json +0 -13
  4. package/drizzle.config.ts +0 -10
  5. package/src/ai/__tests__/chat.test.ts +0 -179
  6. package/src/ai/__tests__/provider.test.ts +0 -198
  7. package/src/ai/__tests__/tools.test.ts +0 -93
  8. package/src/ai/chat.ts +0 -224
  9. package/src/ai/configure.ts +0 -134
  10. package/src/ai/provider.ts +0 -302
  11. package/src/ai/tools.ts +0 -114
  12. package/src/auth/index.ts +0 -47
  13. package/src/auth/oauth.ts +0 -94
  14. package/src/cli/__tests__/commands.test.ts +0 -225
  15. package/src/cli/cmd/agent.ts +0 -97
  16. package/src/cli/cmd/chat.ts +0 -190
  17. package/src/cli/cmd/comment.ts +0 -67
  18. package/src/cli/cmd/config.ts +0 -153
  19. package/src/cli/cmd/feed.ts +0 -53
  20. package/src/cli/cmd/forum.ts +0 -106
  21. package/src/cli/cmd/login.ts +0 -45
  22. package/src/cli/cmd/logout.ts +0 -12
  23. package/src/cli/cmd/me.ts +0 -188
  24. package/src/cli/cmd/post.ts +0 -25
  25. package/src/cli/cmd/publish.ts +0 -64
  26. package/src/cli/cmd/scan.ts +0 -78
  27. package/src/cli/cmd/search.ts +0 -35
  28. package/src/cli/cmd/setup.ts +0 -352
  29. package/src/cli/cmd/tui.ts +0 -20
  30. package/src/cli/cmd/uninstall.ts +0 -281
  31. package/src/cli/cmd/update.ts +0 -123
  32. package/src/cli/cmd/vote.ts +0 -50
  33. package/src/cli/cmd/whoami.ts +0 -18
  34. package/src/cli/mcp-print.ts +0 -6
  35. package/src/cli/ui.ts +0 -250
  36. package/src/config/index.ts +0 -54
  37. package/src/flag/index.ts +0 -23
  38. package/src/global/index.ts +0 -38
  39. package/src/id/index.ts +0 -20
  40. package/src/index.ts +0 -200
  41. package/src/mcp/__tests__/client.test.ts +0 -149
  42. package/src/mcp/__tests__/e2e.ts +0 -327
  43. package/src/mcp/__tests__/integration.ts +0 -148
  44. package/src/mcp/client.ts +0 -148
  45. package/src/server/index.ts +0 -48
  46. package/src/storage/chat.ts +0 -71
  47. package/src/storage/db.ts +0 -85
  48. package/src/storage/schema.sql.ts +0 -39
  49. package/src/storage/schema.ts +0 -1
  50. package/src/tui/app.tsx +0 -165
  51. package/src/tui/commands.ts +0 -186
  52. package/src/tui/context/exit.tsx +0 -15
  53. package/src/tui/context/helper.tsx +0 -25
  54. package/src/tui/context/route.tsx +0 -24
  55. package/src/tui/context/theme.tsx +0 -470
  56. package/src/tui/routes/home.tsx +0 -660
  57. package/src/tui/routes/model.tsx +0 -210
  58. package/src/tui/routes/notifications.tsx +0 -87
  59. package/src/tui/routes/post.tsx +0 -102
  60. package/src/tui/routes/search.tsx +0 -105
  61. package/src/tui/routes/setup.tsx +0 -255
  62. package/src/tui/routes/trending.tsx +0 -107
  63. package/src/util/__tests__/context.test.ts +0 -31
  64. package/src/util/__tests__/lazy.test.ts +0 -37
  65. package/src/util/context.ts +0 -23
  66. package/src/util/error.ts +0 -46
  67. package/src/util/lazy.ts +0 -18
  68. package/src/util/log.ts +0 -142
  69. package/tsconfig.json +0 -11
@@ -1,352 +0,0 @@
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 shimmerLine(text: string, durationMs = 2000): Promise<void> {
34
- const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
35
- const startTime = Date.now()
36
- let i = 0
37
- while (Date.now() - startTime < durationMs) {
38
- Bun.stderr.write(`\r ${UI.Style.TEXT_HIGHLIGHT}${frames[i % frames.length]}${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}${text}${UI.Style.TEXT_NORMAL}`)
39
- i++
40
- await Bun.sleep(80)
41
- }
42
- Bun.stderr.write(`\r ${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL} ${text}\n`)
43
- }
44
-
45
- async function scanAndPublish(): Promise<void> {
46
- // Scan with shimmer animation
47
- const scanPromise = McpBridge.callTool("scan_sessions", { limit: 10 })
48
- await shimmerLine("Scanning local IDE sessions...", 1500)
49
-
50
- let sessions: Array<{ id: string; source: string; project: string; title: string }>
51
- try {
52
- const text = await scanPromise
53
- try {
54
- sessions = JSON.parse(text)
55
- } catch {
56
- console.log(` ${text}`)
57
- return
58
- }
59
- } catch (err) {
60
- UI.warn(`Could not scan sessions: ${err instanceof Error ? err.message : String(err)}`)
61
- await UI.typeText("No worries — you can scan later with /scan in the app.")
62
- return
63
- }
64
-
65
- if (sessions.length === 0) {
66
- await UI.typeText("No IDE sessions found yet. That's okay!")
67
- await UI.typeText("You can scan later with /scan once you've used an AI-powered IDE.")
68
- return
69
- }
70
-
71
- // Show what we found
72
- const sources = [...new Set(sessions.map((s) => s.source))]
73
- console.log("")
74
- await UI.typeText(
75
- `Found ${sessions.length} session${sessions.length > 1 ? "s" : ""} across ${sources.length} IDE${sources.length > 1 ? "s" : ""}: ${sources.join(", ")}`,
76
- { charDelay: 10 },
77
- )
78
- console.log("")
79
-
80
- for (const s of sessions.slice(0, 3)) {
81
- console.log(` ${UI.Style.TEXT_INFO}[${s.source}]${UI.Style.TEXT_NORMAL} ${s.project} — ${s.title.slice(0, 60)}`)
82
- }
83
- if (sessions.length > 3) {
84
- console.log(` ${UI.Style.TEXT_DIM}... and ${sessions.length - 3} more${UI.Style.TEXT_NORMAL}`)
85
- }
86
- console.log("")
87
-
88
- // Analyze with shimmer — show the thinking process step by step
89
- await shimmerLine("Analyzing sessions for interesting insights...", 1200)
90
-
91
- // Dry run — preview (with shimmer while waiting)
92
- let preview: string
93
- try {
94
- const postPromise = McpBridge.callTool("auto_post", { dry_run: true })
95
- await shimmerLine("Crafting a blog post from your best session...", 2000)
96
- preview = await postPromise
97
- } catch (err) {
98
- UI.warn(`Could not generate post: ${err instanceof Error ? err.message : String(err)}`)
99
- await UI.typeText("You can try again later with /publish in the app.")
100
- return
101
- }
102
-
103
- // Display preview with structured layout
104
- const cleaned = UI.cleanMarkdown(preview)
105
- console.log("")
106
- UI.divider()
107
-
108
- // Parse out key fields for better display
109
- const lines = cleaned.split("\n")
110
- let title = ""
111
- let tags = ""
112
- let category = ""
113
- const bodyLines: string[] = []
114
-
115
- for (const line of lines) {
116
- const trimmed = line.trim()
117
- if (!trimmed || trimmed.startsWith("DRY RUN") || trimmed === "---" || trimmed.match(/^─+$/)) continue
118
- if (trimmed.startsWith("Title:")) { title = trimmed.replace("Title:", "").trim(); continue }
119
- if (trimmed.startsWith("Tags:")) { tags = trimmed.replace("Tags:", "").trim(); continue }
120
- if (trimmed.startsWith("Category:")) { category = trimmed.replace("Category:", "").trim(); continue }
121
- if (trimmed.startsWith("Session:")) continue
122
- bodyLines.push(trimmed)
123
- }
124
-
125
- // Structured display
126
- if (title) {
127
- console.log(` ${UI.Style.TEXT_NORMAL_BOLD}📝 ${title}${UI.Style.TEXT_NORMAL}`)
128
- console.log("")
129
- }
130
- if (category || tags) {
131
- const meta: string[] = []
132
- if (category) meta.push(`Category: ${category}`)
133
- if (tags) meta.push(`Tags: ${tags}`)
134
- console.log(` ${UI.Style.TEXT_DIM}${meta.join(" · ")}${UI.Style.TEXT_NORMAL}`)
135
- console.log("")
136
- }
137
- if (bodyLines.length > 0) {
138
- // Show a preview snippet (first few meaningful lines)
139
- const snippet = bodyLines.slice(0, 6)
140
- for (const line of snippet) {
141
- console.log(` ${line}`)
142
- }
143
- if (bodyLines.length > 6) {
144
- console.log(` ${UI.Style.TEXT_DIM}... (${bodyLines.length - 6} more lines)${UI.Style.TEXT_NORMAL}`)
145
- }
146
- }
147
-
148
- UI.divider()
149
-
150
- // Confirm publish
151
- const choice = await UI.waitEnter("Press Enter to publish, or Esc to skip")
152
-
153
- if (choice === "escape") {
154
- await UI.typeText("Skipped. You can publish later with /publish in the app.")
155
- return
156
- }
157
-
158
- // Publish with shimmer
159
- const publishPromise = McpBridge.callTool("auto_post", { dry_run: false })
160
- await shimmerLine("Publishing your post...", 1500)
161
-
162
- try {
163
- const result = await publishPromise
164
- console.log("")
165
-
166
- // Extract URL and details from result
167
- const urlMatch = result.match(/(?:URL|View at|view at)[:\s]*(https?:\/\/\S+)/i)
168
- if (urlMatch) {
169
- UI.success("Post published successfully!")
170
- console.log("")
171
- if (title) {
172
- console.log(` ${UI.Style.TEXT_NORMAL_BOLD}${title}${UI.Style.TEXT_NORMAL}`)
173
- }
174
- console.log(` ${UI.Style.TEXT_HIGHLIGHT}${urlMatch[1]}${UI.Style.TEXT_NORMAL}`)
175
- console.log("")
176
- await UI.typeText("Your first post is live! Others can now read, comment, and vote on it.", { charDelay: 10 })
177
- } else {
178
- UI.success("Post published!")
179
- // Fallback: show cleaned result
180
- const cleanResult = UI.cleanMarkdown(result)
181
- for (const line of cleanResult.split("\n").slice(0, 5)) {
182
- if (line.trim()) console.log(` ${line.trim()}`)
183
- }
184
- }
185
- } catch (err) {
186
- UI.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`)
187
- await UI.typeText("You can try again later with /publish.")
188
- }
189
- }
190
-
191
- // ─── AI Configuration ────────────────────────────────────────────────────────
192
-
193
- async function aiConfigPrompt(): Promise<void> {
194
- const { AIProvider } = await import("../../ai/provider")
195
- const hasKey = await AIProvider.hasAnyKey()
196
-
197
- if (hasKey) {
198
- UI.success("AI provider already configured!")
199
- return
200
- }
201
-
202
- UI.divider()
203
-
204
- await UI.typeText("One more thing — would you like to configure an AI chat provider?")
205
- console.log("")
206
- await UI.typeText("With AI configured, you can interact with the forum using natural language:", { charDelay: 8 })
207
- console.log(` ${UI.Style.TEXT_DIM}"Show me trending posts about TypeScript"${UI.Style.TEXT_NORMAL}`)
208
- console.log(` ${UI.Style.TEXT_DIM}"Analyze my latest coding session"${UI.Style.TEXT_NORMAL}`)
209
- console.log(` ${UI.Style.TEXT_DIM}"Write a post about my React refactoring"${UI.Style.TEXT_NORMAL}`)
210
- console.log("")
211
-
212
- const choice = await UI.waitEnter("Press Enter to configure AI, or Esc to skip")
213
-
214
- if (choice === "escape") {
215
- console.log("")
216
- await UI.typeText("No problem! You can configure AI later with /ai in the app.")
217
- console.log("")
218
- await UI.typeText("Even without AI, you can use slash commands to interact:", { charDelay: 8 })
219
- console.log(` ${UI.Style.TEXT_HIGHLIGHT}/scan${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Scan IDE sessions${UI.Style.TEXT_NORMAL}`)
220
- console.log(` ${UI.Style.TEXT_HIGHLIGHT}/publish${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Publish a post${UI.Style.TEXT_NORMAL}`)
221
- console.log(` ${UI.Style.TEXT_HIGHLIGHT}/feed${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Browse the forum${UI.Style.TEXT_NORMAL}`)
222
- console.log(` ${UI.Style.TEXT_HIGHLIGHT}/theme${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}Change color theme${UI.Style.TEXT_NORMAL}`)
223
- return
224
- }
225
-
226
- // AI config flow: URL → Key with ESC support
227
- console.log("")
228
- console.log(` ${UI.Style.TEXT_NORMAL_BOLD}API URL${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(or press Enter to skip URL, Esc to cancel):${UI.Style.TEXT_NORMAL}`)
229
- const urlResult = await UI.inputWithEscape(` ${UI.Style.TEXT_HIGHLIGHT}❯ ${UI.Style.TEXT_NORMAL}`)
230
-
231
- if (urlResult === null) {
232
- // User pressed Esc
233
- console.log("")
234
- await UI.typeText("Skipped AI configuration. You can configure later with /ai in the app.")
235
- return
236
- }
237
-
238
- const url = urlResult.trim()
239
-
240
- console.log(` ${UI.Style.TEXT_NORMAL_BOLD}API Key${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(press Esc to cancel):${UI.Style.TEXT_NORMAL}`)
241
- const keyResult = await UI.inputWithEscape(` ${UI.Style.TEXT_HIGHLIGHT}❯ ${UI.Style.TEXT_NORMAL}`)
242
-
243
- if (keyResult === null) {
244
- // User pressed Esc
245
- console.log("")
246
- await UI.typeText("Skipped AI configuration. You can configure later with /ai in the app.")
247
- return
248
- }
249
-
250
- const key = keyResult.trim()
251
-
252
- // Both empty → friendly skip
253
- if (!url && !key) {
254
- console.log("")
255
- UI.info("No AI configuration provided — skipping for now.")
256
- await UI.typeText("You can configure AI later with /ai in the app.")
257
- return
258
- }
259
-
260
- // Key empty but URL provided → friendly skip
261
- if (!key) {
262
- console.log("")
263
- UI.info("No API key provided — skipping AI configuration.")
264
- await UI.typeText("You can configure AI later with /ai in the app.")
265
- return
266
- }
267
-
268
- if (key.length < 5) {
269
- UI.warn("API key seems too short, skipping AI configuration.")
270
- await UI.typeText("You can configure AI later with /ai in the app.")
271
- return
272
- }
273
-
274
- try {
275
- const { saveProvider } = await import("../../ai/configure")
276
- await shimmerLine("Detecting API format...", 1500)
277
- const result = await saveProvider(url, key)
278
- if (result.error) {
279
- UI.warn(result.error)
280
- await UI.typeText("You can try again later with /ai in the app.")
281
- } else {
282
- UI.success(`AI configured! (${result.provider})`)
283
- }
284
- } catch (err) {
285
- UI.warn(`Configuration failed: ${err instanceof Error ? err.message : String(err)}`)
286
- await UI.typeText("You can try again later with /ai in the app.")
287
- }
288
- }
289
-
290
- // ─── Setup Command ───────────────────────────────────────────────────────────
291
-
292
- export const SetupCommand: CommandModule = {
293
- command: "setup",
294
- describe: "First-time setup wizard: authenticate, scan, publish, configure AI",
295
- handler: async () => {
296
- // Phase 1: Welcome
297
- console.log(UI.logo())
298
- await UI.typeText("Welcome to CodeBlog!", { charDelay: 20 })
299
- await UI.typeText("The AI-powered coding forum in your terminal.", { charDelay: 15 })
300
- console.log("")
301
-
302
- // Phase 2: Authentication
303
- const alreadyAuthed = await Auth.authenticated()
304
- let authenticated = alreadyAuthed
305
-
306
- if (alreadyAuthed) {
307
- const token = await Auth.get()
308
- UI.success(`Already authenticated as ${token?.username || "user"}!`)
309
- } else {
310
- await UI.typeText("Let's get you set up. First, we need to authenticate your account.")
311
- await UI.typeText("You may need to sign up or log in on the website first.", { charDelay: 10 })
312
- console.log("")
313
-
314
- await UI.waitEnter("Press Enter to open browser...")
315
-
316
- authenticated = await authBrowser()
317
- }
318
-
319
- if (!authenticated) {
320
- console.log("")
321
- UI.info("You can try again with: codeblog setup")
322
- return
323
- }
324
-
325
- const token = await Auth.get()
326
- UI.success(`Authenticated as ${token?.username || "user"}!`)
327
-
328
- // Phase 3: Interactive scan & publish
329
- UI.divider()
330
-
331
- await UI.typeText("Great! Let's see what you've been working on.")
332
- await UI.typeText("I'll scan your local IDE sessions to find interesting coding experiences.", { charDelay: 10 })
333
- console.log("")
334
-
335
- const scanChoice = await UI.waitEnter("Press Enter to continue, or Esc to skip")
336
-
337
- if (scanChoice === "enter") {
338
- await scanAndPublish()
339
- } else {
340
- await UI.typeText("Skipped. You can scan and publish later in the app.")
341
- }
342
-
343
- // Phase 4: AI configuration
344
- await aiConfigPrompt()
345
-
346
- // Phase 5: Transition to TUI
347
- UI.divider()
348
- setupCompleted = true
349
- await UI.typeText("All set! Launching CodeBlog...", { charDelay: 20 })
350
- await Bun.sleep(800)
351
- },
352
- }
@@ -1,20 +0,0 @@
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
- }
@@ -1,281 +0,0 @@
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
- const DIM = "\x1b[90m"
9
- const RESET = "\x1b[0m"
10
- const BOLD = "\x1b[1m"
11
- const RED = "\x1b[91m"
12
- const GREEN = "\x1b[92m"
13
- const YELLOW = "\x1b[93m"
14
- const CYAN = "\x1b[36m"
15
-
16
- const W = 60 // inner width of the box
17
- const BAR = `${DIM}│${RESET}`
18
-
19
- /** Strip ANSI escape sequences to get visible character length */
20
- function visLen(s: string): number {
21
- return s.replace(/\x1b\[[0-9;]*m/g, "").length
22
- }
23
-
24
- function line(text = "") {
25
- const pad = Math.max(0, W - visLen(text) - 1)
26
- console.log(` ${BAR} ${text}${" ".repeat(pad)}${BAR}`)
27
- }
28
-
29
- function lineSuccess(text: string) {
30
- line(`${GREEN}✓${RESET} ${text}`)
31
- }
32
-
33
- function lineWarn(text: string) {
34
- line(`${YELLOW}⚠${RESET} ${text}`)
35
- }
36
-
37
- function lineInfo(text: string) {
38
- line(`${DIM}${text}${RESET}`)
39
- }
40
-
41
- export const UninstallCommand: CommandModule = {
42
- command: "uninstall",
43
- describe: "Uninstall codeblog CLI and remove all local data",
44
- builder: (yargs) =>
45
- yargs.option("keep-data", {
46
- describe: "Keep config, data, and cache (only remove binary)",
47
- type: "boolean",
48
- default: false,
49
- }),
50
- handler: async (args) => {
51
- const keepData = args["keep-data"] as boolean
52
- const binPath = process.execPath
53
- const pkg = await import("../../../package.json")
54
-
55
- console.log(UI.logo())
56
-
57
- // Top border
58
- console.log(` ${DIM}┌${"─".repeat(W)}┐${RESET}`)
59
- line()
60
- line(`${RED}${BOLD}Uninstall CodeBlog${RESET} ${DIM}v${pkg.version}${RESET}`)
61
- line()
62
-
63
- // Show what will be removed
64
- line(`${BOLD}The following will be removed:${RESET}`)
65
- line()
66
- line(` ${DIM}Binary${RESET} ${binPath}`)
67
-
68
- if (!keepData) {
69
- const dirs = [Global.Path.config, Global.Path.data, Global.Path.cache, Global.Path.state]
70
- for (const dir of dirs) {
71
- const label = dir.includes("config") ? "Config" : dir.includes("data") || dir.includes("share") ? "Data" : dir.includes("cache") ? "Cache" : "State"
72
- try {
73
- await fs.access(dir)
74
- line(` ${DIM}${label.padEnd(10)}${RESET}${dir}`)
75
- } catch {
76
- // dir doesn't exist, skip
77
- }
78
- }
79
- }
80
-
81
- if (os.platform() !== "win32") {
82
- const rcFiles = getShellRcFiles()
83
- for (const rc of rcFiles) {
84
- try {
85
- const content = await fs.readFile(rc, "utf-8")
86
- if (content.includes("# codeblog")) {
87
- line(` ${DIM}Shell RC${RESET} ${rc} ${DIM}(PATH entry)${RESET}`)
88
- }
89
- } catch {}
90
- }
91
- }
92
-
93
- line()
94
-
95
- // Separator
96
- console.log(` ${DIM}├${"─".repeat(W)}┤${RESET}`)
97
- line()
98
-
99
- // Confirm
100
- line(`${BOLD}Type "yes" to confirm uninstall:${RESET}`)
101
- process.stderr.write(` ${BAR} ${DIM}> ${RESET}`)
102
- const answer = await readLine()
103
- // Print the line with right border after input
104
- const inputDisplay = answer || ""
105
- const inputLine = `${DIM}> ${RESET}${inputDisplay}`
106
- const inputPad = Math.max(0, W - visLen(inputLine) - 1)
107
- process.stderr.write(`\x1b[A\r ${BAR} ${inputLine}${" ".repeat(inputPad)}${BAR}\n`)
108
-
109
- if (answer.toLowerCase() !== "yes") {
110
- line()
111
- line(`Uninstall cancelled.`)
112
- line()
113
- console.log(` ${DIM}└${"─".repeat(W)}┘${RESET}`)
114
- console.log("")
115
- return
116
- }
117
-
118
- line()
119
-
120
- // Execute uninstall steps
121
- // 1. Remove data directories
122
- if (!keepData) {
123
- const dirs = [Global.Path.config, Global.Path.data, Global.Path.cache, Global.Path.state]
124
- for (const dir of dirs) {
125
- try {
126
- await fs.access(dir)
127
- await fs.rm(dir, { recursive: true, force: true })
128
- lineSuccess(`Removed ${dir}`)
129
- } catch {
130
- // dir doesn't exist
131
- }
132
- }
133
- }
134
-
135
- // 2. Clean shell rc PATH entries (macOS/Linux only)
136
- if (os.platform() !== "win32") {
137
- await cleanShellRc()
138
- }
139
-
140
- // 3. Remove the binary
141
- const binDir = path.dirname(binPath)
142
-
143
- if (os.platform() === "win32") {
144
- lineInfo(`Binary at ${binPath}`)
145
- lineWarn(`On Windows, delete manually after exit:`)
146
- line(` ${CYAN}del "${binPath}"${RESET}`)
147
- await cleanWindowsPath(binDir)
148
- } else {
149
- try {
150
- await fs.unlink(binPath)
151
- lineSuccess(`Removed ${binPath}`)
152
- } catch (e: any) {
153
- if (e.code === "EBUSY" || e.code === "ETXTBSY") {
154
- const { spawn } = await import("child_process")
155
- spawn("sh", ["-c", `sleep 1 && rm -f "${binPath}"`], {
156
- detached: true,
157
- stdio: "ignore",
158
- }).unref()
159
- lineSuccess(`Binary will be removed: ${binPath}`)
160
- } else {
161
- lineWarn(`Could not remove binary: ${e.message}`)
162
- line(` Run manually: ${CYAN}rm "${binPath}"${RESET}`)
163
- }
164
- }
165
- }
166
-
167
- line()
168
-
169
- // Separator
170
- console.log(` ${DIM}├${"─".repeat(W)}┤${RESET}`)
171
- line()
172
- line(`${GREEN}${BOLD}CodeBlog has been uninstalled.${RESET} Goodbye!`)
173
- line()
174
-
175
- // Bottom border
176
- console.log(` ${DIM}└${"─".repeat(W)}┘${RESET}`)
177
- console.log("")
178
- },
179
- }
180
-
181
- // ─── Helpers ──────────────────────────────────────────────────────────────────
182
-
183
- function readLine(): Promise<string> {
184
- const stdin = process.stdin
185
- return new Promise((resolve) => {
186
- const wasRaw = stdin.isRaw
187
- if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
188
-
189
- let buf = ""
190
- const onData = (ch: Buffer) => {
191
- const c = ch.toString("utf8")
192
- if (c === "\u0003") {
193
- if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
194
- stdin.removeListener("data", onData)
195
- process.exit(130)
196
- }
197
- if (c === "\r" || c === "\n") {
198
- if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
199
- stdin.removeListener("data", onData)
200
- process.stderr.write("\n")
201
- resolve(buf)
202
- return
203
- }
204
- if (c === "\u007f" || c === "\b") {
205
- if (buf.length > 0) {
206
- buf = buf.slice(0, -1)
207
- process.stderr.write("\b \b")
208
- }
209
- return
210
- }
211
- const clean = c.replace(/[\x00-\x1f\x7f]/g, "")
212
- if (clean) {
213
- buf += clean
214
- process.stderr.write(clean)
215
- }
216
- }
217
- stdin.on("data", onData)
218
- })
219
- }
220
-
221
- function getShellRcFiles(): string[] {
222
- const home = os.homedir()
223
- return [
224
- path.join(home, ".zshrc"),
225
- path.join(home, ".bashrc"),
226
- path.join(home, ".profile"),
227
- ]
228
- }
229
-
230
- async function cleanShellRc() {
231
- for (const rc of getShellRcFiles()) {
232
- try {
233
- const content = await fs.readFile(rc, "utf-8")
234
- if (!content.includes("# codeblog")) continue
235
-
236
- const lines = content.split("\n")
237
- const filtered: string[] = []
238
- for (let i = 0; i < lines.length; i++) {
239
- if (lines[i]!.trim() === "# codeblog") {
240
- if (i + 1 < lines.length && lines[i + 1]!.includes("export PATH=")) {
241
- i++
242
- }
243
- if (filtered.length > 0 && filtered[filtered.length - 1]!.trim() === "") {
244
- filtered.pop()
245
- }
246
- continue
247
- }
248
- filtered.push(lines[i]!)
249
- }
250
-
251
- await fs.writeFile(rc, filtered.join("\n"), "utf-8")
252
- lineSuccess(`Cleaned PATH from ${rc}`)
253
- } catch {
254
- // file doesn't exist or not readable
255
- }
256
- }
257
- }
258
-
259
- async function cleanWindowsPath(binDir: string) {
260
- try {
261
- const { exec } = await import("child_process")
262
- const { promisify } = await import("util")
263
- const execAsync = promisify(exec)
264
-
265
- const { stdout } = await execAsync(
266
- `powershell -Command "[Environment]::GetEnvironmentVariable('Path','User')"`,
267
- )
268
- const currentPath = stdout.trim()
269
- const parts = currentPath.split(";").filter((p) => p && p !== binDir)
270
- const newPath = parts.join(";")
271
-
272
- if (newPath !== currentPath) {
273
- await execAsync(
274
- `powershell -Command "[Environment]::SetEnvironmentVariable('Path','${newPath}','User')"`,
275
- )
276
- lineSuccess(`Removed ${binDir} from user PATH`)
277
- }
278
- } catch {
279
- lineWarn("Could not clean PATH. Remove manually from System Settings.")
280
- }
281
- }