@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/README.md +300 -0
- package/dist/index.js +32041 -73342
- package/package.json +23 -7
- package/src/api/client.ts +0 -53
- package/src/api/pings.ts +0 -79
- package/src/api/threads.ts +0 -14
- package/src/api/users.ts +0 -43
- package/src/auth/keychain.ts +0 -28
- package/src/auth/pkce.ts +0 -24
- package/src/commands/accept.ts +0 -43
- package/src/commands/block.ts +0 -48
- package/src/commands/chat.ts +0 -181
- package/src/commands/ignore.ts +0 -43
- package/src/commands/inbox.ts +0 -98
- package/src/commands/leaderboard.ts +0 -51
- package/src/commands/login.ts +0 -117
- package/src/commands/logout.ts +0 -29
- package/src/commands/ping.ts +0 -57
- package/src/commands/search.ts +0 -64
- package/src/commands/status.ts +0 -116
- package/src/commands/whoami.ts +0 -57
- package/src/config.ts +0 -39
- package/src/index.ts +0 -147
- package/src/ui/ChatView.tsx +0 -195
- package/src/ui/InboxWatch.tsx +0 -134
- package/tsconfig.json +0 -21
package/package.json
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gitping/cli",
|
|
3
|
-
"version": "0.0
|
|
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
|
-
}
|
package/src/api/threads.ts
DELETED
|
@@ -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
|
-
}
|
package/src/auth/keychain.ts
DELETED
|
@@ -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
|
-
}
|
package/src/commands/accept.ts
DELETED
|
@@ -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
|
-
}
|
package/src/commands/block.ts
DELETED
|
@@ -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
|
-
}
|
package/src/commands/chat.ts
DELETED
|
@@ -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
|
-
}
|
package/src/commands/ignore.ts
DELETED
|
@@ -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
|
-
}
|