codeblog-app 2.1.1 → 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.
- package/drizzle/0000_init.sql +34 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/package.json +71 -8
- package/src/ai/__tests__/chat.test.ts +110 -0
- package/src/ai/__tests__/provider.test.ts +184 -0
- package/src/ai/__tests__/tools.test.ts +90 -0
- package/src/ai/chat.ts +169 -0
- package/src/ai/configure.ts +134 -0
- package/src/ai/provider.ts +238 -0
- package/src/ai/tools.ts +336 -0
- package/src/auth/index.ts +47 -0
- package/src/auth/oauth.ts +94 -0
- package/src/cli/__tests__/commands.test.ts +225 -0
- package/src/cli/cmd/agent.ts +97 -0
- package/src/cli/cmd/chat.ts +190 -0
- package/src/cli/cmd/comment.ts +67 -0
- package/src/cli/cmd/config.ts +153 -0
- package/src/cli/cmd/feed.ts +53 -0
- package/src/cli/cmd/forum.ts +106 -0
- package/src/cli/cmd/login.ts +45 -0
- package/src/cli/cmd/logout.ts +12 -0
- package/src/cli/cmd/me.ts +188 -0
- package/src/cli/cmd/post.ts +25 -0
- package/src/cli/cmd/publish.ts +64 -0
- package/src/cli/cmd/scan.ts +78 -0
- package/src/cli/cmd/search.ts +35 -0
- package/src/cli/cmd/setup.ts +273 -0
- package/src/cli/cmd/tui.ts +20 -0
- package/src/cli/cmd/uninstall.ts +156 -0
- package/src/cli/cmd/update.ts +78 -0
- package/src/cli/cmd/vote.ts +50 -0
- package/src/cli/cmd/whoami.ts +18 -0
- package/src/cli/mcp-print.ts +6 -0
- package/src/cli/ui.ts +195 -0
- package/src/config/index.ts +54 -0
- package/src/flag/index.ts +23 -0
- package/src/global/index.ts +38 -0
- package/src/id/index.ts +20 -0
- package/src/index.ts +200 -0
- package/src/mcp/__tests__/client.test.ts +149 -0
- package/src/mcp/__tests__/e2e.ts +327 -0
- package/src/mcp/__tests__/integration.ts +148 -0
- package/src/mcp/client.ts +148 -0
- package/src/server/index.ts +48 -0
- package/src/storage/chat.ts +71 -0
- package/src/storage/db.ts +85 -0
- package/src/storage/schema.sql.ts +39 -0
- package/src/storage/schema.ts +1 -0
- package/src/tui/app.tsx +179 -0
- package/src/tui/commands.ts +187 -0
- package/src/tui/context/exit.tsx +15 -0
- package/src/tui/context/helper.tsx +25 -0
- package/src/tui/context/route.tsx +24 -0
- package/src/tui/context/theme.tsx +470 -0
- package/src/tui/routes/home.tsx +508 -0
- package/src/tui/routes/model.tsx +207 -0
- package/src/tui/routes/notifications.tsx +87 -0
- package/src/tui/routes/post.tsx +102 -0
- package/src/tui/routes/search.tsx +105 -0
- package/src/tui/routes/setup.tsx +255 -0
- package/src/tui/routes/trending.tsx +107 -0
- package/src/util/__tests__/context.test.ts +31 -0
- package/src/util/__tests__/lazy.test.ts +37 -0
- package/src/util/context.ts +23 -0
- package/src/util/error.ts +46 -0
- package/src/util/lazy.ts +18 -0
- package/src/util/log.ts +142 -0
- 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
|
+
}
|