@gitping/cli 0.0.1 → 0.1.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.
package/package.json CHANGED
@@ -1,7 +1,27 @@
1
1
  {
2
2
  "name": "@gitping/cli",
3
- "version": "0.0.1",
4
- "description": "GitPing terminal client",
3
+ "version": "0.1.0",
4
+ "description": "GitPing terminal client — Ping the poeple",
5
+ "keywords": [
6
+ "gitping",
7
+ "cli",
8
+ "github",
9
+ "messaging",
10
+ "developer",
11
+ "terminal",
12
+ "ping"
13
+ ],
14
+ "homepage": "https://github.com/aialok/gitping#readme",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/aialok/gitping.git",
18
+ "directory": "packages/cli"
19
+ },
20
+ "license": "MIT",
21
+ "files": [
22
+ "dist",
23
+ "README.md"
24
+ ],
5
25
  "bin": {
6
26
  "gitping": "./dist/index.js"
7
27
  },
@@ -11,22 +31,18 @@
11
31
  "typecheck": "tsc --noEmit"
12
32
  },
13
33
  "dependencies": {
14
- "@gitping/shared": "workspace:*",
15
34
  "chalk": "^5.6.2",
16
35
  "commander": "^14.0.3",
17
36
  "conf": "^15.1.0",
18
- "ink": "^5.2.0",
19
37
  "keytar": "^7.9.0",
20
38
  "ky": "^1.14.3",
21
39
  "open": "^11.0.0",
22
40
  "ora": "^9.3.0",
23
- "react": "^18.3.1",
24
- "react-devtools-core": "^7.0.1",
25
41
  "ws": "^8.19.0"
26
42
  },
27
43
  "devDependencies": {
44
+ "@gitping/shared": "workspace:*",
28
45
  "@types/node": "^25.5.0",
29
- "@types/react": "^18.3.18",
30
46
  "@types/ws": "^8.18.1",
31
47
  "bun-types": "^1.3.10",
32
48
  "typescript": "^5.9.3"
package/src/api/client.ts DELETED
@@ -1,53 +0,0 @@
1
- import ky, { type KyInstance, type Options as KyOptions } from 'ky'
2
- import { getApiBaseUrl } from '../config.ts'
3
- import { getToken } from '../auth/keychain.ts'
4
-
5
- export class ApiError extends Error {
6
- constructor(
7
- public readonly code: string,
8
- message: string,
9
- public readonly status: number,
10
- ) {
11
- super(message)
12
- this.name = 'ApiError'
13
- }
14
- }
15
-
16
- async function buildHeaders(): Promise<Record<string, string>> {
17
- const token = await getToken()
18
- if (!token) return {}
19
- return { Authorization: `Bearer ${token}` }
20
- }
21
-
22
- /**
23
- * Creates a ky instance with auth headers injected per-request.
24
- * All commands must go through this client — never call ky directly.
25
- */
26
- export async function createClient(extraOptions?: KyOptions): Promise<KyInstance> {
27
- const headers = await buildHeaders()
28
- return ky.create({
29
- prefixUrl: getApiBaseUrl(),
30
- headers,
31
- hooks: {
32
- afterResponse: [
33
- async (_req, _opts, response) => {
34
- if (!response.ok) {
35
- let code = 'INTERNAL_ERROR'
36
- let message = response.statusText
37
- try {
38
- const body = (await response.clone().json()) as {
39
- error?: { code?: string; message?: string }
40
- }
41
- code = body?.error?.code ?? code
42
- message = body?.error?.message ?? message
43
- } catch {
44
- /* ignore parse errors */
45
- }
46
- throw new ApiError(code, message, response.status)
47
- }
48
- },
49
- ],
50
- },
51
- ...extraOptions,
52
- })
53
- }
package/src/api/pings.ts DELETED
@@ -1,79 +0,0 @@
1
- import { createClient } from './client.ts'
2
- import type { Ping } from '@gitping/shared'
3
-
4
- /** Server extends Ping with an optional thread_id when status is accepted. */
5
- export interface PingWithThread extends Ping {
6
- thread_id?: string
7
- }
8
-
9
- export interface SendPingInput {
10
- recipient: string
11
- message: string
12
- category: string
13
- }
14
-
15
- export interface PingsFilter {
16
- filter?: 'unread' | 'pending' | 'sent'
17
- cursor?: string
18
- }
19
-
20
- export interface PingsPage {
21
- data: PingWithThread[]
22
- nextCursor: string | null
23
- }
24
-
25
- /** Send a new ping to a recipient. */
26
- export async function sendPing(input: SendPingInput): Promise<Ping> {
27
- const client = await createClient()
28
- return client.post('v1/pings', { json: input }).json<Ping>()
29
- }
30
-
31
- /** List pings in the authenticated user's inbox. */
32
- export async function listPings(opts: PingsFilter = {}): Promise<PingsPage> {
33
- const client = await createClient()
34
- const searchParams: Record<string, string> = {}
35
- if (opts.filter) searchParams['filter'] = opts.filter
36
- if (opts.cursor) searchParams['cursor'] = opts.cursor
37
- return client.get('v1/pings', { searchParams }).json<PingsPage>()
38
- }
39
-
40
- /** Get a single ping by ID. */
41
- export async function getPing(id: string): Promise<PingWithThread> {
42
- const client = await createClient()
43
- return client.get(`v1/pings/${id}`).json<PingWithThread>()
44
- }
45
-
46
- export interface AcceptPingResponse {
47
- thread_id: string
48
- ping_id: string
49
- }
50
-
51
- export interface PingActionResponse {
52
- message: string
53
- }
54
-
55
- /** Accept a ping by ID. Returns thread_id for opening a chat. */
56
- export async function acceptPing(id: string): Promise<AcceptPingResponse> {
57
- const client = await createClient()
58
- return client.patch(`v1/pings/${id}/accept`).json<AcceptPingResponse>()
59
- }
60
-
61
- /** Accept a ping by the sender's username. Returns thread_id for opening a chat. */
62
- export async function acceptPingByUsername(username: string): Promise<AcceptPingResponse> {
63
- const client = await createClient()
64
- return client
65
- .patch(`v1/pings/by-sender/${encodeURIComponent(username)}/accept`)
66
- .json<AcceptPingResponse>()
67
- }
68
-
69
- /** Ignore a ping by ID. */
70
- export async function ignorePing(id: string): Promise<PingActionResponse> {
71
- const client = await createClient()
72
- return client.patch(`v1/pings/${id}/ignore`).json<PingActionResponse>()
73
- }
74
-
75
- /** Block the sender of a ping by ID. */
76
- export async function blockPing(id: string): Promise<PingActionResponse> {
77
- const client = await createClient()
78
- return client.patch(`v1/pings/${id}/block`).json<PingActionResponse>()
79
- }
@@ -1,14 +0,0 @@
1
- import { createClient } from './client.ts'
2
- import type { Thread, Message } from '@gitping/shared'
3
-
4
- /** Get a thread and its messages by thread ID. */
5
- export async function getThread(id: string): Promise<{ thread: Thread; messages: Message[] }> {
6
- const client = await createClient()
7
- return client.get(`v1/threads/${id}`).json<{ thread: Thread; messages: Message[] }>()
8
- }
9
-
10
- /** Send a message in a thread. */
11
- export async function sendMessage(threadId: string, body: string): Promise<Message> {
12
- const client = await createClient()
13
- return client.post(`v1/threads/${threadId}/messages`, { json: { body } }).json<Message>()
14
- }
package/src/api/users.ts DELETED
@@ -1,43 +0,0 @@
1
- import { createClient } from './client.ts'
2
- import type { User, PublicProfile } from '@gitping/shared'
3
-
4
- export interface UpdateProfileInput {
5
- availability?: string
6
- availability_msg?: string
7
- bio?: string
8
- }
9
-
10
- /** Get the authenticated user's full profile. */
11
- export async function getMe(): Promise<User> {
12
- const client = await createClient()
13
- return client.get('v1/me').json<User>()
14
- }
15
-
16
- /** Update the authenticated user's profile. */
17
- export async function updateMe(input: UpdateProfileInput): Promise<User> {
18
- const client = await createClient()
19
- return client.patch('v1/me', { json: input }).json<User>()
20
- }
21
-
22
- /** Get a user's public profile by GitHub username. */
23
- export async function getUser(username: string): Promise<PublicProfile> {
24
- const client = await createClient()
25
- return client.get(`v1/users/${username}`).json<PublicProfile>()
26
- }
27
-
28
- export interface LeaderboardEntry {
29
- github_username: string
30
- avatar_url: string | null
31
- rep_score: number
32
- rep_tier: string
33
- ping_count: number
34
- }
35
-
36
- /** Get the leaderboard of most-pinged developers. */
37
- export async function getLeaderboard(rising?: boolean): Promise<LeaderboardEntry[]> {
38
- const client = await createClient()
39
- const searchParams: Record<string, string> = {}
40
- if (rising) searchParams['rising'] = 'true'
41
- // Server needs to implement: GET /v1/users/leaderboard?rising=true
42
- return client.get('v1/users/leaderboard', { searchParams }).json<LeaderboardEntry[]>()
43
- }
@@ -1,28 +0,0 @@
1
- const SERVICE = 'gitping-cli'
2
- const ACCOUNT = 'jwt'
3
-
4
- /** Returns the stored JWT, or null if not found. */
5
- export async function getToken(): Promise<string | null> {
6
- try {
7
- const { default: keytar } = await import('keytar')
8
- return keytar.getPassword(SERVICE, ACCOUNT)
9
- } catch {
10
- return null
11
- }
12
- }
13
-
14
- /** Persists the JWT in the OS keychain. */
15
- export async function setToken(token: string): Promise<void> {
16
- const { default: keytar } = await import('keytar')
17
- await keytar.setPassword(SERVICE, ACCOUNT, token)
18
- }
19
-
20
- /** Removes the stored JWT from the OS keychain. */
21
- export async function deleteToken(): Promise<void> {
22
- try {
23
- const { default: keytar } = await import('keytar')
24
- await keytar.deletePassword(SERVICE, ACCOUNT)
25
- } catch {
26
- // nothing to delete
27
- }
28
- }
package/src/auth/pkce.ts DELETED
@@ -1,24 +0,0 @@
1
- import crypto from 'crypto'
2
-
3
- /**
4
- * Generates a PKCE code_verifier: a cryptographically random URL-safe string.
5
- * RFC 7636 §4.1
6
- */
7
- export function generateCodeVerifier(): string {
8
- return crypto.randomBytes(32).toString('base64url')
9
- }
10
-
11
- /**
12
- * Derives the code_challenge from a code_verifier using SHA-256.
13
- * RFC 7636 §4.2
14
- */
15
- export function generateCodeChallenge(verifier: string): string {
16
- return crypto.createHash('sha256').update(verifier).digest('base64url')
17
- }
18
-
19
- /**
20
- * Generates a cryptographically random state parameter to prevent CSRF.
21
- */
22
- export function generateState(): string {
23
- return crypto.randomBytes(16).toString('hex')
24
- }
@@ -1,43 +0,0 @@
1
- import chalk from 'chalk'
2
- import ora from 'ora'
3
- import { acceptPing, acceptPingByUsername } from '../api/pings.ts'
4
- import { getToken } from '../auth/keychain.ts'
5
-
6
- interface AcceptOptions {
7
- json?: boolean
8
- }
9
-
10
- export async function acceptCommand(target: string, options: AcceptOptions): Promise<void> {
11
- const token = await getToken()
12
- if (!token) {
13
- console.error(chalk.red('Not logged in. Run `gitping login` first.'))
14
- throw Object.assign(new Error('Not authenticated'), { exitCode: 2 })
15
- }
16
-
17
- const isUsername = target.startsWith('@')
18
- const label = isUsername ? target : target.slice(0, 8)
19
-
20
- const spinner = ora(`Accepting ping from ${isUsername ? target : `ping ${label}…`}`).start()
21
-
22
- let result
23
- try {
24
- if (isUsername) {
25
- const username = target.slice(1)
26
- result = await acceptPingByUsername(username)
27
- } else {
28
- result = await acceptPing(target)
29
- }
30
- } catch (err) {
31
- spinner.fail('Failed to accept ping')
32
- throw Object.assign(err instanceof Error ? err : new Error(String(err)), { exitCode: 3 })
33
- }
34
-
35
- spinner.succeed(
36
- chalk.green(`Ping accepted! Thread opened.`) +
37
- chalk.dim(` (thread ${result.thread_id.slice(0, 8)})`),
38
- )
39
-
40
- if (options.json) {
41
- console.log(JSON.stringify(result))
42
- }
43
- }
@@ -1,48 +0,0 @@
1
- import chalk from 'chalk'
2
- import ora from 'ora'
3
- import { blockPing, listPings } from '../api/pings.ts'
4
- import { getToken } from '../auth/keychain.ts'
5
-
6
- interface BlockOptions {
7
- json?: boolean
8
- }
9
-
10
- export async function blockCommand(rawTarget: string, options: BlockOptions): Promise<void> {
11
- const token = await getToken()
12
- if (!token) {
13
- console.error(chalk.red('Not logged in. Run `gitping login` first.'))
14
- throw Object.assign(new Error('Not authenticated'), { exitCode: 2 })
15
- }
16
-
17
- const isUsername = rawTarget.startsWith('@')
18
- const username = isUsername ? rawTarget.slice(1) : rawTarget
19
-
20
- const spinner = ora(
21
- `Blocking ${isUsername ? '@' + username : 'sender of ping ' + username.slice(0, 8)}…`,
22
- ).start()
23
-
24
- try {
25
- if (isUsername) {
26
- // Find the most recent delivered ping from this sender and block it
27
- const page = await listPings()
28
- const ping = page.data.find((p) => p.sender_username === username && p.status === 'delivered')
29
- if (!ping) {
30
- spinner.fail(`No actionable ping found from @${username}`)
31
- throw Object.assign(new Error('Ping not found'), { exitCode: 1 })
32
- }
33
- const blocked = await blockPing(ping.id)
34
- spinner.succeed(chalk.red(`Blocked @${username}.`))
35
- if (options.json) console.log(JSON.stringify(blocked))
36
- } else {
37
- // Treat rawTarget as a ping ID
38
- const blocked = await blockPing(rawTarget)
39
- spinner.succeed(chalk.red(`Blocked sender of ping ${rawTarget.slice(0, 8)}.`))
40
- if (options.json) console.log(JSON.stringify(blocked))
41
- }
42
- } catch (err) {
43
- const typed = err as NodeJS.ErrnoException & { exitCode?: number }
44
- if (typed.exitCode) throw err
45
- spinner.fail('Failed to block')
46
- throw Object.assign(err instanceof Error ? err : new Error(String(err)), { exitCode: 3 })
47
- }
48
- }
@@ -1,181 +0,0 @@
1
- import chalk from 'chalk'
2
- import ora from 'ora'
3
- import readline from 'node:readline'
4
- import WebSocket from 'ws'
5
- import { listPings } from '../api/pings.ts'
6
- import { getMe } from '../api/users.ts'
7
- import { getThread, sendMessage } from '../api/threads.ts'
8
- import { getToken } from '../auth/keychain.ts'
9
- import { getApiBaseUrl } from '../config.ts'
10
- import type { Message, WSEvent } from '@gitping/shared'
11
- import { WSEventType } from '@gitping/shared'
12
-
13
- interface ChatOptions {
14
- json?: boolean
15
- }
16
-
17
- export async function chatCommand(rawUsername: string, _options: ChatOptions): Promise<void> {
18
- const token = await getToken()
19
- if (!token) {
20
- console.error(chalk.red('Not logged in. Run `gitping login` first.'))
21
- throw Object.assign(new Error('Not authenticated'), { exitCode: 2 })
22
- }
23
-
24
- const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername
25
-
26
- const spinner = ora(`Opening chat with @${username}…`).start()
27
-
28
- let me: { id: string }
29
- let threadId: string
30
- try {
31
- me = await getMe()
32
- // Fetch both received and sent pings — the current user may be either side
33
- const [receivedPage, sentPage] = await Promise.all([listPings(), listPings({ filter: 'sent' })])
34
- const allPings = [...receivedPage.data, ...sentPage.data]
35
- const ping = allPings.find(
36
- (p) =>
37
- p.status === 'accepted' &&
38
- (p.sender_username === username || p.recipient_username === username),
39
- )
40
-
41
- if (!ping) {
42
- spinner.fail(
43
- `No accepted ping found with @${username}. ` +
44
- `Send a ping first with \`gitping ping @${username} "message"\` and have them accept it.`,
45
- )
46
- throw Object.assign(new Error('No accepted ping'), { exitCode: 1 })
47
- }
48
-
49
- if (!ping.thread_id) {
50
- spinner.fail(
51
- `No thread found for accepted ping with @${username}. The server did not return a thread_id.`,
52
- )
53
- throw Object.assign(new Error('No thread_id on accepted ping'), { exitCode: 1 })
54
- }
55
- threadId = ping.thread_id
56
- } catch (err) {
57
- const typed = err as { exitCode?: number }
58
- if (typed.exitCode) throw err
59
- spinner.fail('Failed to load chat')
60
- throw Object.assign(err instanceof Error ? err : new Error(String(err)), { exitCode: 3 })
61
- }
62
-
63
- spinner.succeed(`Chat with @${username} opened`)
64
-
65
- // Load existing messages
66
- let messages: Message[] = []
67
- try {
68
- const data = await getThread(threadId)
69
- messages = data.messages
70
- } catch {
71
- // Non-fatal — we can still chat without history
72
- }
73
-
74
- // Print header
75
- console.log()
76
- console.log(chalk.cyan.bold(' ┌─────────────────────────────────────────┐'))
77
- console.log(
78
- chalk.cyan.bold(' │') +
79
- chalk.bold(` ⚡ GitPing Chat with @${username}`) +
80
- ' '.repeat(Math.max(0, 20 - username.length)) +
81
- chalk.cyan.bold('│'),
82
- )
83
- console.log(chalk.cyan.bold(' └─────────────────────────────────────────┘'))
84
- console.log()
85
-
86
- // Print existing messages
87
- for (const msg of messages) {
88
- printMessage(msg, me.id)
89
- }
90
- if (messages.length === 0) {
91
- console.log(chalk.dim(' No messages yet. Start the conversation!'))
92
- }
93
- console.log()
94
-
95
- // Connect WebSocket for live messages
96
- const baseUrl = getApiBaseUrl().replace(/^http/, 'ws')
97
- const ws = new WebSocket(`${baseUrl}/v1/ws?token=${encodeURIComponent(token)}`)
98
-
99
- let connected = false
100
- ws.on('open', () => {
101
- connected = true
102
- })
103
-
104
- ws.on('message', (data) => {
105
- try {
106
- const event = JSON.parse(data.toString()) as WSEvent
107
- if (event.type === WSEventType.MESSAGE_RECEIVED) {
108
- const payload = event.payload as { message: Message }
109
- const msg = payload.message ?? (event.payload as Message)
110
- if (msg.thread_id === threadId && msg.sender_id !== me.id) {
111
- // Clear the current input line, print the message, then redisplay prompt
112
- readline.clearLine(process.stdout, 0)
113
- readline.cursorTo(process.stdout, 0)
114
- printMessage(msg, me.id)
115
- rl.prompt()
116
- }
117
- }
118
- } catch {
119
- /* ignore malformed events */
120
- }
121
- })
122
-
123
- ws.on('error', () => {
124
- console.log(chalk.yellow(' ⚠ WebSocket connection failed — messages may be delayed'))
125
- })
126
-
127
- // Set up readline for input
128
- const rl = readline.createInterface({
129
- input: process.stdin,
130
- output: process.stdout,
131
- prompt: chalk.cyan(' > '),
132
- terminal: true,
133
- })
134
-
135
- rl.prompt()
136
-
137
- rl.on('line', async (line) => {
138
- const body = line.trim()
139
- if (!body) {
140
- rl.prompt()
141
- return
142
- }
143
-
144
- if (body === '/quit' || body === '/exit' || body === '/q') {
145
- rl.close()
146
- return
147
- }
148
-
149
- try {
150
- const msg = await sendMessage(threadId, body)
151
- // Move up to overwrite the input line, print our message
152
- printMessage(msg, me.id)
153
- } catch {
154
- console.log(chalk.red(' ✗ Failed to send message'))
155
- }
156
- rl.prompt()
157
- })
158
-
159
- rl.on('close', () => {
160
- ws.close()
161
- console.log()
162
- console.log(chalk.dim(' Chat ended. Goodbye!'))
163
- console.log()
164
- })
165
-
166
- // Wait for readline to close (user types /quit or Ctrl+C/Ctrl+D)
167
- await new Promise<void>((resolve) => {
168
- rl.on('close', resolve)
169
- })
170
- }
171
-
172
- function printMessage(msg: Message, myUserId: string): void {
173
- const isMe = msg.sender_id === myUserId
174
- const time = new Date(msg.created_at).toLocaleTimeString([], {
175
- hour: '2-digit',
176
- minute: '2-digit',
177
- })
178
- const sender = isMe ? chalk.cyan.bold('You') : chalk.white.bold('Them')
179
- const prefix = isMe ? chalk.cyan(' ▸ ') : chalk.white(' ◂ ')
180
- console.log(`${prefix}${sender} ${chalk.dim(time)} ${msg.body}`)
181
- }
@@ -1,43 +0,0 @@
1
- import chalk from 'chalk'
2
- import ora from 'ora'
3
- import { ignorePing, listPings } from '../api/pings.ts'
4
- import { getToken } from '../auth/keychain.ts'
5
-
6
- interface IgnoreOptions {
7
- json?: boolean
8
- }
9
-
10
- export async function ignoreCommand(target: string, options: IgnoreOptions): Promise<void> {
11
- const token = await getToken()
12
- if (!token) {
13
- console.error(chalk.red('Not logged in. Run `gitping login` first.'))
14
- throw Object.assign(new Error('Not authenticated'), { exitCode: 2 })
15
- }
16
-
17
- const isUsername = target.startsWith('@')
18
- const spinner = ora(isUsername ? `Ignoring ping from ${target}…` : 'Ignoring ping…').start()
19
-
20
- try {
21
- if (isUsername) {
22
- const username = target.slice(1)
23
- const page = await listPings()
24
- const ping = page.data.find((p) => p.sender_username === username && p.status === 'delivered')
25
- if (!ping) {
26
- spinner.fail(`No actionable ping found from @${username}`)
27
- throw Object.assign(new Error('Ping not found'), { exitCode: 1 })
28
- }
29
- const result = await ignorePing(ping.id)
30
- spinner.succeed(chalk.dim(`Ping from @${username} ignored.`))
31
- if (options.json) console.log(JSON.stringify(result))
32
- } else {
33
- const result = await ignorePing(target)
34
- spinner.succeed(chalk.dim(`Ping ${target.slice(0, 8)} ignored.`))
35
- if (options.json) console.log(JSON.stringify(result))
36
- }
37
- } catch (err) {
38
- const typed = err as { exitCode?: number }
39
- if (typed.exitCode) throw err
40
- spinner.fail('Failed to ignore ping')
41
- throw Object.assign(err instanceof Error ? err : new Error(String(err)), { exitCode: 3 })
42
- }
43
- }