codeblog-app 2.3.0 → 2.3.1

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 (83) 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 +73 -8
  5. package/src/ai/__tests__/chat.test.ts +188 -0
  6. package/src/ai/__tests__/compat.test.ts +46 -0
  7. package/src/ai/__tests__/home.ai-stream.integration.test.ts +77 -0
  8. package/src/ai/__tests__/provider-registry.test.ts +61 -0
  9. package/src/ai/__tests__/provider.test.ts +238 -0
  10. package/src/ai/__tests__/stream-events.test.ts +152 -0
  11. package/src/ai/__tests__/tools.test.ts +93 -0
  12. package/src/ai/chat.ts +336 -0
  13. package/src/ai/configure.ts +143 -0
  14. package/src/ai/models.ts +26 -0
  15. package/src/ai/provider-registry.ts +150 -0
  16. package/src/ai/provider.ts +264 -0
  17. package/src/ai/stream-events.ts +64 -0
  18. package/src/ai/tools.ts +118 -0
  19. package/src/ai/types.ts +105 -0
  20. package/src/auth/index.ts +49 -0
  21. package/src/auth/oauth.ts +123 -0
  22. package/src/cli/__tests__/commands.test.ts +229 -0
  23. package/src/cli/cmd/agent.ts +97 -0
  24. package/src/cli/cmd/ai.ts +10 -0
  25. package/src/cli/cmd/chat.ts +190 -0
  26. package/src/cli/cmd/comment.ts +67 -0
  27. package/src/cli/cmd/config.ts +153 -0
  28. package/src/cli/cmd/feed.ts +53 -0
  29. package/src/cli/cmd/forum.ts +106 -0
  30. package/src/cli/cmd/login.ts +45 -0
  31. package/src/cli/cmd/logout.ts +12 -0
  32. package/src/cli/cmd/me.ts +188 -0
  33. package/src/cli/cmd/post.ts +25 -0
  34. package/src/cli/cmd/publish.ts +64 -0
  35. package/src/cli/cmd/scan.ts +78 -0
  36. package/src/cli/cmd/search.ts +35 -0
  37. package/src/cli/cmd/setup.ts +622 -0
  38. package/src/cli/cmd/tui.ts +20 -0
  39. package/src/cli/cmd/uninstall.ts +281 -0
  40. package/src/cli/cmd/update.ts +123 -0
  41. package/src/cli/cmd/vote.ts +50 -0
  42. package/src/cli/cmd/whoami.ts +18 -0
  43. package/src/cli/mcp-print.ts +6 -0
  44. package/src/cli/ui.ts +357 -0
  45. package/src/config/index.ts +92 -0
  46. package/src/flag/index.ts +23 -0
  47. package/src/global/index.ts +38 -0
  48. package/src/id/index.ts +20 -0
  49. package/src/index.ts +203 -0
  50. package/src/mcp/__tests__/client.test.ts +149 -0
  51. package/src/mcp/__tests__/e2e.ts +331 -0
  52. package/src/mcp/__tests__/integration.ts +148 -0
  53. package/src/mcp/client.ts +118 -0
  54. package/src/server/index.ts +48 -0
  55. package/src/storage/chat.ts +73 -0
  56. package/src/storage/db.ts +85 -0
  57. package/src/storage/schema.sql.ts +39 -0
  58. package/src/storage/schema.ts +1 -0
  59. package/src/tui/__tests__/input-intent.test.ts +27 -0
  60. package/src/tui/__tests__/stream-assembler.test.ts +33 -0
  61. package/src/tui/ai-stream.ts +28 -0
  62. package/src/tui/app.tsx +210 -0
  63. package/src/tui/commands.ts +220 -0
  64. package/src/tui/context/exit.tsx +15 -0
  65. package/src/tui/context/helper.tsx +25 -0
  66. package/src/tui/context/route.tsx +24 -0
  67. package/src/tui/context/theme.tsx +471 -0
  68. package/src/tui/input-intent.ts +26 -0
  69. package/src/tui/routes/home.tsx +1060 -0
  70. package/src/tui/routes/model.tsx +210 -0
  71. package/src/tui/routes/notifications.tsx +87 -0
  72. package/src/tui/routes/post.tsx +102 -0
  73. package/src/tui/routes/search.tsx +105 -0
  74. package/src/tui/routes/setup.tsx +267 -0
  75. package/src/tui/routes/trending.tsx +107 -0
  76. package/src/tui/stream-assembler.ts +49 -0
  77. package/src/util/__tests__/context.test.ts +31 -0
  78. package/src/util/__tests__/lazy.test.ts +37 -0
  79. package/src/util/context.ts +23 -0
  80. package/src/util/error.ts +46 -0
  81. package/src/util/lazy.ts +18 -0
  82. package/src/util/log.ts +144 -0
  83. package/tsconfig.json +11 -0
@@ -0,0 +1,281 @@
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
+ }
@@ -0,0 +1,123 @@
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 checkController = new AbortController()
21
+ const checkTimeout = setTimeout(() => checkController.abort(), 10_000)
22
+ let res: Response
23
+ try {
24
+ res = await fetch("https://registry.npmjs.org/codeblog-app/latest", { signal: checkController.signal })
25
+ } catch (e: any) {
26
+ clearTimeout(checkTimeout)
27
+ if (e.name === "AbortError") {
28
+ UI.error("Version check timed out (10s). Please check your network and try again.")
29
+ } else {
30
+ UI.error(`Failed to check for updates: ${e.message}`)
31
+ }
32
+ process.exitCode = 1
33
+ return
34
+ }
35
+ clearTimeout(checkTimeout)
36
+ if (!res.ok) {
37
+ UI.error("Failed to check for updates")
38
+ process.exitCode = 1
39
+ return
40
+ }
41
+
42
+ const data = await res.json() as { version: string }
43
+ const latest = data.version
44
+
45
+ if (current === latest && !args.force) {
46
+ UI.success(`Already on latest version v${current}`)
47
+ return
48
+ }
49
+
50
+ UI.info(`Updating v${current} → v${latest}...`)
51
+
52
+ const os = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "darwin" : "linux"
53
+ const arch = process.arch === "arm64" ? "arm64" : "x64"
54
+ const platform = `${os}-${arch}`
55
+ const pkg_name = `codeblog-app-${platform}`
56
+ const url = `https://registry.npmjs.org/${pkg_name}/-/${pkg_name}-${latest}.tgz`
57
+
58
+ const tmpdir = (await import("os")).tmpdir()
59
+ const path = await import("path")
60
+ const fs = await import("fs/promises")
61
+ const tmp = path.join(tmpdir, `codeblog-update-${Date.now()}`)
62
+ await fs.mkdir(tmp, { recursive: true })
63
+
64
+ UI.info("Downloading...")
65
+ const tgz = path.join(tmp, "pkg.tgz")
66
+ const dlController = new AbortController()
67
+ const dlTimeout = setTimeout(() => dlController.abort(), 60_000)
68
+ let dlRes: Response
69
+ try {
70
+ dlRes = await fetch(url, { signal: dlController.signal })
71
+ } catch (e: any) {
72
+ clearTimeout(dlTimeout)
73
+ await fs.rm(tmp, { recursive: true, force: true }).catch(() => {})
74
+ if (e.name === "AbortError") {
75
+ UI.error("Download timed out (60s). Please check your network and try again.")
76
+ } else {
77
+ UI.error(`Download failed: ${e.message}`)
78
+ }
79
+ process.exitCode = 1
80
+ return
81
+ }
82
+ clearTimeout(dlTimeout)
83
+ if (!dlRes.ok) {
84
+ UI.error(`Failed to download update for ${platform} (HTTP ${dlRes.status})`)
85
+ await fs.rm(tmp, { recursive: true, force: true }).catch(() => {})
86
+ process.exitCode = 1
87
+ return
88
+ }
89
+
90
+ const arrayBuf = await dlRes.arrayBuffer()
91
+ await fs.writeFile(tgz, Buffer.from(arrayBuf))
92
+
93
+ UI.info("Extracting...")
94
+ const proc = Bun.spawn(["tar", "-xzf", tgz, "-C", tmp], { stdout: "ignore", stderr: "ignore" })
95
+ await proc.exited
96
+
97
+ const bin = process.execPath
98
+ const ext = os === "windows" ? ".exe" : ""
99
+ const src = path.join(tmp, "package", "bin", `codeblog${ext}`)
100
+
101
+ UI.info("Installing...")
102
+ // On macOS/Linux, remove the running binary first to avoid ETXTBSY
103
+ if (os !== "windows") {
104
+ try {
105
+ await fs.unlink(bin)
106
+ } catch {
107
+ // ignore if removal fails
108
+ }
109
+ }
110
+ await fs.copyFile(src, bin)
111
+ if (os !== "windows") {
112
+ await fs.chmod(bin, 0o755)
113
+ }
114
+ if (os === "darwin") {
115
+ const cs = Bun.spawn(["codesign", "--sign", "-", "--force", bin], { stdout: "ignore", stderr: "ignore" })
116
+ await cs.exited
117
+ }
118
+
119
+ await fs.rm(tmp, { recursive: true, force: true })
120
+
121
+ UI.success(`Updated to v${latest}!`)
122
+ },
123
+ }
@@ -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
+ }
@@ -0,0 +1,18 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { mcpPrint } from "../mcp-print"
3
+ import { UI } from "../ui"
4
+
5
+ export const WhoamiCommand: CommandModule = {
6
+ command: "whoami",
7
+ describe: "Show current auth status",
8
+ handler: async () => {
9
+ try {
10
+ console.log("")
11
+ await mcpPrint("codeblog_status")
12
+ console.log("")
13
+ } catch (err) {
14
+ UI.error(`Status check failed: ${err instanceof Error ? err.message : String(err)}`)
15
+ process.exitCode = 1
16
+ }
17
+ },
18
+ }
@@ -0,0 +1,6 @@
1
+ import { McpBridge } from "../mcp/client"
2
+
3
+ export async function mcpPrint(tool: string, args: Record<string, unknown> = {}) {
4
+ const text = await McpBridge.callTool(tool, args)
5
+ for (const line of text.split("\n")) console.log(` ${line}`)
6
+ }