codeblog-app 2.1.4 → 2.1.7
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 +6 -6
- package/src/cli/cmd/setup.ts +121 -42
- package/src/cli/cmd/uninstall.ts +168 -43
- package/src/cli/ui.ts +55 -0
- package/src/tui/routes/home.tsx +16 -2
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.
|
|
4
|
+
"version": "2.1.7",
|
|
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.
|
|
60
|
-
"codeblog-app-darwin-x64": "2.1.
|
|
61
|
-
"codeblog-app-linux-arm64": "2.1.
|
|
62
|
-
"codeblog-app-linux-x64": "2.1.
|
|
63
|
-
"codeblog-app-windows-x64": "2.1.
|
|
59
|
+
"codeblog-app-darwin-arm64": "2.1.7",
|
|
60
|
+
"codeblog-app-darwin-x64": "2.1.7",
|
|
61
|
+
"codeblog-app-linux-arm64": "2.1.7",
|
|
62
|
+
"codeblog-app-linux-x64": "2.1.7",
|
|
63
|
+
"codeblog-app-windows-x64": "2.1.7"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@ai-sdk/anthropic": "^3.0.44",
|
package/src/cli/cmd/setup.ts
CHANGED
|
@@ -30,14 +30,26 @@ async function authBrowser(): Promise<boolean> {
|
|
|
30
30
|
|
|
31
31
|
// ─── Scan & Publish ──────────────────────────────────────────────────────────
|
|
32
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
|
+
|
|
33
45
|
async function scanAndPublish(): Promise<void> {
|
|
34
|
-
// Scan
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
// Scan with shimmer animation
|
|
47
|
+
const scanPromise = McpBridge.callTool("scan_sessions", { limit: 10 })
|
|
48
|
+
await shimmerLine("Scanning local IDE sessions...", 1500)
|
|
37
49
|
|
|
38
50
|
let sessions: Array<{ id: string; source: string; project: string; title: string }>
|
|
39
51
|
try {
|
|
40
|
-
const text = await
|
|
52
|
+
const text = await scanPromise
|
|
41
53
|
try {
|
|
42
54
|
sessions = JSON.parse(text)
|
|
43
55
|
} catch {
|
|
@@ -58,6 +70,7 @@ async function scanAndPublish(): Promise<void> {
|
|
|
58
70
|
|
|
59
71
|
// Show what we found
|
|
60
72
|
const sources = [...new Set(sessions.map((s) => s.source))]
|
|
73
|
+
console.log("")
|
|
61
74
|
await UI.typeText(
|
|
62
75
|
`Found ${sessions.length} session${sessions.length > 1 ? "s" : ""} across ${sources.length} IDE${sources.length > 1 ? "s" : ""}: ${sources.join(", ")}`,
|
|
63
76
|
{ charDelay: 10 },
|
|
@@ -72,65 +85,97 @@ async function scanAndPublish(): Promise<void> {
|
|
|
72
85
|
}
|
|
73
86
|
console.log("")
|
|
74
87
|
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
// Analyze with shimmer — show the thinking process step by step
|
|
89
|
+
await shimmerLine("Analyzing sessions for interesting insights...", 1200)
|
|
77
90
|
|
|
78
|
-
// Dry run — preview
|
|
91
|
+
// Dry run — preview (with shimmer while waiting)
|
|
79
92
|
let preview: string
|
|
80
93
|
try {
|
|
81
|
-
|
|
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
|
|
82
97
|
} catch (err) {
|
|
83
98
|
UI.warn(`Could not generate post: ${err instanceof Error ? err.message : String(err)}`)
|
|
84
99
|
await UI.typeText("You can try again later with /publish in the app.")
|
|
85
100
|
return
|
|
86
101
|
}
|
|
87
102
|
|
|
88
|
-
// Display preview
|
|
103
|
+
// Display preview with structured layout
|
|
89
104
|
const cleaned = UI.cleanMarkdown(preview)
|
|
105
|
+
console.log("")
|
|
90
106
|
UI.divider()
|
|
91
107
|
|
|
92
|
-
//
|
|
108
|
+
// Parse out key fields for better display
|
|
93
109
|
const lines = cleaned.split("\n")
|
|
110
|
+
let title = ""
|
|
111
|
+
let tags = ""
|
|
112
|
+
let category = ""
|
|
113
|
+
const bodyLines: string[] = []
|
|
114
|
+
|
|
94
115
|
for (const line of lines) {
|
|
95
116
|
const trimmed = line.trim()
|
|
96
|
-
if (!trimmed)
|
|
97
|
-
|
|
98
|
-
|
|
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}`)
|
|
99
142
|
}
|
|
100
|
-
if (
|
|
101
|
-
|
|
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}`)
|
|
143
|
+
if (bodyLines.length > 6) {
|
|
144
|
+
console.log(` ${UI.Style.TEXT_DIM}... (${bodyLines.length - 6} more lines)${UI.Style.TEXT_NORMAL}`)
|
|
109
145
|
}
|
|
110
146
|
}
|
|
111
147
|
|
|
112
148
|
UI.divider()
|
|
113
149
|
|
|
114
150
|
// Confirm publish
|
|
115
|
-
|
|
116
|
-
const choice = await UI.waitEnter()
|
|
151
|
+
const choice = await UI.waitEnter("Press Enter to publish, or Esc to skip")
|
|
117
152
|
|
|
118
153
|
if (choice === "escape") {
|
|
119
154
|
await UI.typeText("Skipped. You can publish later with /publish in the app.")
|
|
120
155
|
return
|
|
121
156
|
}
|
|
122
157
|
|
|
123
|
-
// Publish
|
|
124
|
-
|
|
158
|
+
// Publish with shimmer
|
|
159
|
+
const publishPromise = McpBridge.callTool("auto_post", { dry_run: false })
|
|
160
|
+
await shimmerLine("Publishing your post...", 1500)
|
|
161
|
+
|
|
125
162
|
try {
|
|
126
|
-
const result = await
|
|
163
|
+
const result = await publishPromise
|
|
127
164
|
console.log("")
|
|
128
165
|
|
|
129
|
-
// Extract URL from result
|
|
166
|
+
// Extract URL and details from result
|
|
130
167
|
const urlMatch = result.match(/(?:URL|View at|view at)[:\s]*(https?:\/\/\S+)/i)
|
|
131
168
|
if (urlMatch) {
|
|
132
|
-
UI.success(
|
|
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 })
|
|
133
177
|
} else {
|
|
178
|
+
UI.success("Post published!")
|
|
134
179
|
// Fallback: show cleaned result
|
|
135
180
|
const cleanResult = UI.cleanMarkdown(result)
|
|
136
181
|
for (const line of cleanResult.split("\n").slice(0, 5)) {
|
|
@@ -164,8 +209,7 @@ async function aiConfigPrompt(): Promise<void> {
|
|
|
164
209
|
console.log(` ${UI.Style.TEXT_DIM}"Write a post about my React refactoring"${UI.Style.TEXT_NORMAL}`)
|
|
165
210
|
console.log("")
|
|
166
211
|
|
|
167
|
-
|
|
168
|
-
const choice = await UI.waitEnter()
|
|
212
|
+
const choice = await UI.waitEnter("Press Enter to configure AI, or Esc to skip")
|
|
169
213
|
|
|
170
214
|
if (choice === "escape") {
|
|
171
215
|
console.log("")
|
|
@@ -179,21 +223,58 @@ async function aiConfigPrompt(): Promise<void> {
|
|
|
179
223
|
return
|
|
180
224
|
}
|
|
181
225
|
|
|
182
|
-
// AI config flow: URL → Key
|
|
226
|
+
// AI config flow: URL → Key with ESC support
|
|
183
227
|
console.log("")
|
|
184
|
-
|
|
185
|
-
const
|
|
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
|
+
}
|
|
186
267
|
|
|
187
|
-
if (
|
|
188
|
-
UI.warn("API key too short, skipping AI configuration.")
|
|
268
|
+
if (key.length < 5) {
|
|
269
|
+
UI.warn("API key seems too short, skipping AI configuration.")
|
|
189
270
|
await UI.typeText("You can configure AI later with /ai in the app.")
|
|
190
271
|
return
|
|
191
272
|
}
|
|
192
273
|
|
|
193
274
|
try {
|
|
194
275
|
const { saveProvider } = await import("../../ai/configure")
|
|
195
|
-
|
|
196
|
-
const result = await saveProvider(url
|
|
276
|
+
await shimmerLine("Detecting API format...", 1500)
|
|
277
|
+
const result = await saveProvider(url, key)
|
|
197
278
|
if (result.error) {
|
|
198
279
|
UI.warn(result.error)
|
|
199
280
|
await UI.typeText("You can try again later with /ai in the app.")
|
|
@@ -230,8 +311,7 @@ export const SetupCommand: CommandModule = {
|
|
|
230
311
|
await UI.typeText("You may need to sign up or log in on the website first.", { charDelay: 10 })
|
|
231
312
|
console.log("")
|
|
232
313
|
|
|
233
|
-
|
|
234
|
-
await UI.waitEnter()
|
|
314
|
+
await UI.waitEnter("Press Enter to open browser...")
|
|
235
315
|
|
|
236
316
|
authenticated = await authBrowser()
|
|
237
317
|
}
|
|
@@ -252,8 +332,7 @@ export const SetupCommand: CommandModule = {
|
|
|
252
332
|
await UI.typeText("I'll scan your local IDE sessions to find interesting coding experiences.", { charDelay: 10 })
|
|
253
333
|
console.log("")
|
|
254
334
|
|
|
255
|
-
|
|
256
|
-
const scanChoice = await UI.waitEnter()
|
|
335
|
+
const scanChoice = await UI.waitEnter("Press Enter to continue, or Esc to skip")
|
|
257
336
|
|
|
258
337
|
if (scanChoice === "enter") {
|
|
259
338
|
await scanAndPublish()
|
package/src/cli/cmd/uninstall.ts
CHANGED
|
@@ -5,6 +5,39 @@ import fs from "fs/promises"
|
|
|
5
5
|
import path from "path"
|
|
6
6
|
import os from "os"
|
|
7
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
|
+
|
|
8
41
|
export const UninstallCommand: CommandModule = {
|
|
9
42
|
command: "uninstall",
|
|
10
43
|
describe: "Uninstall codeblog CLI and remove all local data",
|
|
@@ -15,35 +48,86 @@ export const UninstallCommand: CommandModule = {
|
|
|
15
48
|
default: false,
|
|
16
49
|
}),
|
|
17
50
|
handler: async (args) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
}
|
|
27
79
|
}
|
|
28
|
-
UI.println("")
|
|
29
80
|
|
|
30
|
-
|
|
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
|
+
|
|
31
109
|
if (answer.toLowerCase() !== "yes") {
|
|
32
|
-
|
|
110
|
+
line()
|
|
111
|
+
line(`Uninstall cancelled.`)
|
|
112
|
+
line()
|
|
113
|
+
console.log(` ${DIM}└${"─".repeat(W)}┘${RESET}`)
|
|
114
|
+
console.log("")
|
|
33
115
|
return
|
|
34
116
|
}
|
|
35
117
|
|
|
36
|
-
|
|
118
|
+
line()
|
|
37
119
|
|
|
120
|
+
// Execute uninstall steps
|
|
38
121
|
// 1. Remove data directories
|
|
39
|
-
if (!
|
|
122
|
+
if (!keepData) {
|
|
40
123
|
const dirs = [Global.Path.config, Global.Path.data, Global.Path.cache, Global.Path.state]
|
|
41
124
|
for (const dir of dirs) {
|
|
42
125
|
try {
|
|
126
|
+
await fs.access(dir)
|
|
43
127
|
await fs.rm(dir, { recursive: true, force: true })
|
|
44
|
-
|
|
128
|
+
lineSuccess(`Removed ${dir}`)
|
|
45
129
|
} catch {
|
|
46
|
-
//
|
|
130
|
+
// dir doesn't exist
|
|
47
131
|
}
|
|
48
132
|
}
|
|
49
133
|
}
|
|
@@ -53,67 +137,109 @@ export const UninstallCommand: CommandModule = {
|
|
|
53
137
|
await cleanShellRc()
|
|
54
138
|
}
|
|
55
139
|
|
|
56
|
-
// 3. Remove the binary
|
|
57
|
-
const binPath = process.execPath
|
|
140
|
+
// 3. Remove the binary
|
|
58
141
|
const binDir = path.dirname(binPath)
|
|
59
142
|
|
|
60
143
|
if (os.platform() === "win32") {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
UI.println(` ${UI.Style.TEXT_HIGHLIGHT}del "${binPath}"${UI.Style.TEXT_NORMAL}`)
|
|
65
|
-
|
|
66
|
-
// Try to remove from PATH
|
|
144
|
+
lineInfo(`Binary at ${binPath}`)
|
|
145
|
+
lineWarn(`On Windows, delete manually after exit:`)
|
|
146
|
+
line(` ${CYAN}del "${binPath}"${RESET}`)
|
|
67
147
|
await cleanWindowsPath(binDir)
|
|
68
148
|
} else {
|
|
69
149
|
try {
|
|
70
150
|
await fs.unlink(binPath)
|
|
71
|
-
|
|
151
|
+
lineSuccess(`Removed ${binPath}`)
|
|
72
152
|
} catch (e: any) {
|
|
73
153
|
if (e.code === "EBUSY" || e.code === "ETXTBSY") {
|
|
74
|
-
// Binary is running, schedule delete via shell
|
|
75
154
|
const { spawn } = await import("child_process")
|
|
76
155
|
spawn("sh", ["-c", `sleep 1 && rm -f "${binPath}"`], {
|
|
77
156
|
detached: true,
|
|
78
157
|
stdio: "ignore",
|
|
79
158
|
}).unref()
|
|
80
|
-
|
|
159
|
+
lineSuccess(`Binary will be removed: ${binPath}`)
|
|
81
160
|
} else {
|
|
82
|
-
|
|
83
|
-
|
|
161
|
+
lineWarn(`Could not remove binary: ${e.message}`)
|
|
162
|
+
line(` Run manually: ${CYAN}rm "${binPath}"${RESET}`)
|
|
84
163
|
}
|
|
85
164
|
}
|
|
86
165
|
}
|
|
87
166
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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("")
|
|
91
178
|
},
|
|
92
179
|
}
|
|
93
180
|
|
|
94
|
-
|
|
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[] {
|
|
95
222
|
const home = os.homedir()
|
|
96
|
-
|
|
223
|
+
return [
|
|
97
224
|
path.join(home, ".zshrc"),
|
|
98
225
|
path.join(home, ".bashrc"),
|
|
99
226
|
path.join(home, ".profile"),
|
|
100
227
|
]
|
|
228
|
+
}
|
|
101
229
|
|
|
102
|
-
|
|
230
|
+
async function cleanShellRc() {
|
|
231
|
+
for (const rc of getShellRcFiles()) {
|
|
103
232
|
try {
|
|
104
233
|
const content = await fs.readFile(rc, "utf-8")
|
|
105
234
|
if (!content.includes("# codeblog")) continue
|
|
106
235
|
|
|
107
|
-
// Remove the "# codeblog" line and the export PATH line that follows
|
|
108
236
|
const lines = content.split("\n")
|
|
109
237
|
const filtered: string[] = []
|
|
110
238
|
for (let i = 0; i < lines.length; i++) {
|
|
111
239
|
if (lines[i]!.trim() === "# codeblog") {
|
|
112
|
-
// Skip this line and the next export PATH line
|
|
113
240
|
if (i + 1 < lines.length && lines[i + 1]!.includes("export PATH=")) {
|
|
114
|
-
i++
|
|
241
|
+
i++
|
|
115
242
|
}
|
|
116
|
-
// Also skip a preceding blank line if present
|
|
117
243
|
if (filtered.length > 0 && filtered[filtered.length - 1]!.trim() === "") {
|
|
118
244
|
filtered.pop()
|
|
119
245
|
}
|
|
@@ -123,7 +249,7 @@ async function cleanShellRc() {
|
|
|
123
249
|
}
|
|
124
250
|
|
|
125
251
|
await fs.writeFile(rc, filtered.join("\n"), "utf-8")
|
|
126
|
-
|
|
252
|
+
lineSuccess(`Cleaned PATH from ${rc}`)
|
|
127
253
|
} catch {
|
|
128
254
|
// file doesn't exist or not readable
|
|
129
255
|
}
|
|
@@ -136,7 +262,6 @@ async function cleanWindowsPath(binDir: string) {
|
|
|
136
262
|
const { promisify } = await import("util")
|
|
137
263
|
const execAsync = promisify(exec)
|
|
138
264
|
|
|
139
|
-
// Read current user PATH
|
|
140
265
|
const { stdout } = await execAsync(
|
|
141
266
|
`powershell -Command "[Environment]::GetEnvironmentVariable('Path','User')"`,
|
|
142
267
|
)
|
|
@@ -148,9 +273,9 @@ async function cleanWindowsPath(binDir: string) {
|
|
|
148
273
|
await execAsync(
|
|
149
274
|
`powershell -Command "[Environment]::SetEnvironmentVariable('Path','${newPath}','User')"`,
|
|
150
275
|
)
|
|
151
|
-
|
|
276
|
+
lineSuccess(`Removed ${binDir} from user PATH`)
|
|
152
277
|
}
|
|
153
278
|
} catch {
|
|
154
|
-
|
|
279
|
+
lineWarn("Could not clean PATH. Remove manually from System Settings.")
|
|
155
280
|
}
|
|
156
281
|
}
|
package/src/cli/ui.ts
CHANGED
|
@@ -72,6 +72,61 @@ export namespace UI {
|
|
|
72
72
|
})
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Input with ESC support. Returns null if user presses Escape, otherwise the input string.
|
|
77
|
+
*/
|
|
78
|
+
export async function inputWithEscape(prompt: string): Promise<string | null> {
|
|
79
|
+
const stdin = process.stdin
|
|
80
|
+
process.stderr.write(prompt)
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const wasRaw = stdin.isRaw
|
|
84
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
|
|
85
|
+
|
|
86
|
+
let buf = ""
|
|
87
|
+
const onData = (ch: Buffer) => {
|
|
88
|
+
const c = ch.toString("utf8")
|
|
89
|
+
if (c === "\u0003") {
|
|
90
|
+
// Ctrl+C
|
|
91
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
|
|
92
|
+
stdin.removeListener("data", onData)
|
|
93
|
+
process.exit(130)
|
|
94
|
+
}
|
|
95
|
+
if (c === "\x1b") {
|
|
96
|
+
// Escape
|
|
97
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
|
|
98
|
+
stdin.removeListener("data", onData)
|
|
99
|
+
process.stderr.write("\n")
|
|
100
|
+
resolve(null)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (c === "\r" || c === "\n") {
|
|
104
|
+
// Enter
|
|
105
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
|
|
106
|
+
stdin.removeListener("data", onData)
|
|
107
|
+
process.stderr.write("\n")
|
|
108
|
+
resolve(buf)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (c === "\u007f" || c === "\b") {
|
|
112
|
+
// Backspace
|
|
113
|
+
if (buf.length > 0) {
|
|
114
|
+
buf = buf.slice(0, -1)
|
|
115
|
+
process.stderr.write("\b \b")
|
|
116
|
+
}
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
// Regular character
|
|
120
|
+
const clean = c.replace(/[\x00-\x1f\x7f]/g, "")
|
|
121
|
+
if (clean) {
|
|
122
|
+
buf += clean
|
|
123
|
+
process.stderr.write(clean)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
stdin.on("data", onData)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
75
130
|
export async function password(prompt: string): Promise<string> {
|
|
76
131
|
const readline = require("readline")
|
|
77
132
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true })
|
package/src/tui/routes/home.tsx
CHANGED
|
@@ -234,11 +234,25 @@ export function Home(props: {
|
|
|
234
234
|
const v = aiUrl().trim()
|
|
235
235
|
if (v && !v.startsWith("http")) { showMsg("URL must start with http:// or https://", theme.colors.error); return }
|
|
236
236
|
setAiMode("key")
|
|
237
|
-
showMsg("Now paste your API key:", theme.colors.primary)
|
|
237
|
+
showMsg("Now paste your API key (or press Esc to cancel):", theme.colors.primary)
|
|
238
238
|
return
|
|
239
239
|
}
|
|
240
240
|
if (aiMode() === "key") {
|
|
241
|
-
|
|
241
|
+
const url = aiUrl().trim()
|
|
242
|
+
const key = aiKey().trim()
|
|
243
|
+
// Both empty → friendly skip
|
|
244
|
+
if (!url && !key) {
|
|
245
|
+
showMsg("No AI configuration provided — skipped. Use /ai anytime to configure.", theme.colors.warning)
|
|
246
|
+
setAiMode("")
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
// Key empty but URL provided → friendly skip
|
|
250
|
+
if (!key) {
|
|
251
|
+
showMsg("No API key provided — skipped. Use /ai anytime to configure.", theme.colors.warning)
|
|
252
|
+
setAiMode("")
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
if (key.length < 5) { showMsg("API key too short", theme.colors.error); return }
|
|
242
256
|
saveAI()
|
|
243
257
|
return
|
|
244
258
|
}
|