noslop 0.1.0 → 0.2.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.
@@ -0,0 +1,49 @@
1
+ import type { XCredentials } from './config.js';
2
+ /**
3
+ * Response from posting a tweet
4
+ */
5
+ export interface PostedTweet {
6
+ id: string;
7
+ text: string;
8
+ url: string;
9
+ }
10
+ /**
11
+ * Post a tweet using X API v2
12
+ */
13
+ export declare function postTweet(text: string, credentials: XCredentials): Promise<PostedTweet>;
14
+ /**
15
+ * Delete a tweet using X API v2
16
+ */
17
+ export declare function deleteTweet(tweetId: string, credentials: XCredentials): Promise<void>;
18
+ /**
19
+ * Verify credentials by getting the authenticated user
20
+ */
21
+ export declare function verifyCredentials(credentials: XCredentials): Promise<{
22
+ id: string;
23
+ username: string;
24
+ } | null>;
25
+ /**
26
+ * OAuth 1.0a request token response
27
+ */
28
+ export interface OAuthRequestToken {
29
+ oauthToken: string;
30
+ oauthTokenSecret: string;
31
+ authorizeUrl: string;
32
+ }
33
+ /**
34
+ * OAuth 1.0a access token response
35
+ */
36
+ export interface OAuthAccessToken {
37
+ accessToken: string;
38
+ accessTokenSecret: string;
39
+ userId: string;
40
+ screenName: string;
41
+ }
42
+ /**
43
+ * Get OAuth request token (step 1 of PIN flow)
44
+ */
45
+ export declare function getOAuthRequestToken(apiKey: string, apiSecret: string): Promise<OAuthRequestToken>;
46
+ /**
47
+ * Exchange PIN for access token (step 3 of PIN flow)
48
+ */
49
+ export declare function getOAuthAccessToken(apiKey: string, apiSecret: string, oauthToken: string, oauthTokenSecret: string, pin: string): Promise<OAuthAccessToken>;
@@ -0,0 +1,208 @@
1
+ import crypto from 'crypto';
2
+ const API_BASE = 'https://api.twitter.com/2';
3
+ /**
4
+ * Generate OAuth 1.0a signature
5
+ */
6
+ function generateOAuthSignature(method, url, params, consumerSecret, tokenSecret) {
7
+ const signatureBaseString = [
8
+ method.toUpperCase(),
9
+ encodeURIComponent(url),
10
+ encodeURIComponent(Object.keys(params)
11
+ .sort()
12
+ .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
13
+ .join('&')),
14
+ ].join('&');
15
+ const signingKey = `${encodeURIComponent(consumerSecret)}&${encodeURIComponent(tokenSecret)}`;
16
+ return crypto.createHmac('sha1', signingKey).update(signatureBaseString).digest('base64');
17
+ }
18
+ /**
19
+ * Generate OAuth 1.0a Authorization header
20
+ */
21
+ function generateOAuthHeader(method, url, credentials, bodyParams = {}) {
22
+ const oauthParams = {
23
+ oauth_consumer_key: credentials.apiKey,
24
+ oauth_nonce: crypto.randomBytes(16).toString('hex'),
25
+ oauth_signature_method: 'HMAC-SHA1',
26
+ oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
27
+ oauth_token: credentials.accessToken,
28
+ oauth_version: '1.0',
29
+ };
30
+ // Include body params in signature for POST requests
31
+ const allParams = { ...oauthParams, ...bodyParams };
32
+ oauthParams.oauth_signature = generateOAuthSignature(method, url, allParams, credentials.apiSecret, credentials.accessTokenSecret);
33
+ const headerString = Object.keys(oauthParams)
34
+ .sort()
35
+ .map(k => `${encodeURIComponent(k)}="${encodeURIComponent(oauthParams[k])}"`)
36
+ .join(', ');
37
+ return `OAuth ${headerString}`;
38
+ }
39
+ /**
40
+ * Post a tweet using X API v2
41
+ */
42
+ export async function postTweet(text, credentials) {
43
+ const url = `${API_BASE}/tweets`;
44
+ const response = await fetch(url, {
45
+ method: 'POST',
46
+ headers: {
47
+ Authorization: generateOAuthHeader('POST', url, credentials),
48
+ 'Content-Type': 'application/json',
49
+ },
50
+ body: JSON.stringify({ text }),
51
+ });
52
+ if (!response.ok) {
53
+ const errorText = await response.text();
54
+ throw new Error(`Failed to post tweet (${response.status}): ${errorText}`);
55
+ }
56
+ const result = (await response.json());
57
+ return {
58
+ id: result.data.id,
59
+ text: result.data.text,
60
+ url: `https://x.com/${credentials.screenName}/status/${result.data.id}`,
61
+ };
62
+ }
63
+ /**
64
+ * Delete a tweet using X API v2
65
+ */
66
+ export async function deleteTweet(tweetId, credentials) {
67
+ const url = `${API_BASE}/tweets/${tweetId}`;
68
+ const response = await fetch(url, {
69
+ method: 'DELETE',
70
+ headers: {
71
+ Authorization: generateOAuthHeader('DELETE', url, credentials),
72
+ },
73
+ });
74
+ if (!response.ok) {
75
+ const errorText = await response.text();
76
+ throw new Error(`Failed to delete tweet (${response.status}): ${errorText}`);
77
+ }
78
+ }
79
+ /**
80
+ * Verify credentials by getting the authenticated user
81
+ */
82
+ export async function verifyCredentials(credentials) {
83
+ const url = `${API_BASE}/users/me`;
84
+ try {
85
+ const response = await fetch(url, {
86
+ method: 'GET',
87
+ headers: {
88
+ Authorization: generateOAuthHeader('GET', url, credentials),
89
+ },
90
+ });
91
+ if (!response.ok) {
92
+ return null;
93
+ }
94
+ const result = (await response.json());
95
+ return {
96
+ id: result.data.id,
97
+ username: result.data.username,
98
+ };
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ // ============================================
105
+ // OAuth PIN Flow (for authentication)
106
+ // ============================================
107
+ const OAUTH_BASE = 'https://api.twitter.com/oauth';
108
+ /**
109
+ * Generate OAuth signature for requests without access token (request_token flow)
110
+ */
111
+ function generateOAuthSignatureForRequestToken(method, url, params, consumerSecret, tokenSecret = '') {
112
+ const signatureBaseString = [
113
+ method.toUpperCase(),
114
+ encodeURIComponent(url),
115
+ encodeURIComponent(Object.keys(params)
116
+ .sort()
117
+ .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
118
+ .join('&')),
119
+ ].join('&');
120
+ const signingKey = `${encodeURIComponent(consumerSecret)}&${encodeURIComponent(tokenSecret)}`;
121
+ return crypto.createHmac('sha1', signingKey).update(signatureBaseString).digest('base64');
122
+ }
123
+ /**
124
+ * Get OAuth request token (step 1 of PIN flow)
125
+ */
126
+ export async function getOAuthRequestToken(apiKey, apiSecret) {
127
+ const url = `${OAUTH_BASE}/request_token`;
128
+ const oauthParams = {
129
+ oauth_callback: 'oob', // PIN-based flow
130
+ oauth_consumer_key: apiKey,
131
+ oauth_nonce: crypto.randomBytes(16).toString('hex'),
132
+ oauth_signature_method: 'HMAC-SHA1',
133
+ oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
134
+ oauth_version: '1.0',
135
+ };
136
+ oauthParams.oauth_signature = generateOAuthSignatureForRequestToken('POST', url, oauthParams, apiSecret);
137
+ const headerString = Object.keys(oauthParams)
138
+ .sort()
139
+ .map(k => `${encodeURIComponent(k)}="${encodeURIComponent(oauthParams[k])}"`)
140
+ .join(', ');
141
+ const response = await fetch(url, {
142
+ method: 'POST',
143
+ headers: {
144
+ Authorization: `OAuth ${headerString}`,
145
+ },
146
+ });
147
+ if (!response.ok) {
148
+ const errorText = await response.text();
149
+ throw new Error(`Failed to get request token: ${errorText}`);
150
+ }
151
+ const text = await response.text();
152
+ const params = new URLSearchParams(text);
153
+ const oauthToken = params.get('oauth_token');
154
+ const oauthTokenSecret = params.get('oauth_token_secret');
155
+ if (!oauthToken || !oauthTokenSecret) {
156
+ throw new Error('Invalid response from request_token endpoint');
157
+ }
158
+ return {
159
+ oauthToken,
160
+ oauthTokenSecret,
161
+ authorizeUrl: `${OAUTH_BASE}/authorize?oauth_token=${oauthToken}`,
162
+ };
163
+ }
164
+ /**
165
+ * Exchange PIN for access token (step 3 of PIN flow)
166
+ */
167
+ export async function getOAuthAccessToken(apiKey, apiSecret, oauthToken, oauthTokenSecret, pin) {
168
+ const url = `${OAUTH_BASE}/access_token`;
169
+ const oauthParams = {
170
+ oauth_consumer_key: apiKey,
171
+ oauth_nonce: crypto.randomBytes(16).toString('hex'),
172
+ oauth_signature_method: 'HMAC-SHA1',
173
+ oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
174
+ oauth_token: oauthToken,
175
+ oauth_verifier: pin,
176
+ oauth_version: '1.0',
177
+ };
178
+ oauthParams.oauth_signature = generateOAuthSignatureForRequestToken('POST', url, oauthParams, apiSecret, oauthTokenSecret);
179
+ const headerString = Object.keys(oauthParams)
180
+ .sort()
181
+ .map(k => `${encodeURIComponent(k)}="${encodeURIComponent(oauthParams[k])}"`)
182
+ .join(', ');
183
+ const response = await fetch(url, {
184
+ method: 'POST',
185
+ headers: {
186
+ Authorization: `OAuth ${headerString}`,
187
+ },
188
+ });
189
+ if (!response.ok) {
190
+ const errorText = await response.text();
191
+ throw new Error(`Failed to get access token: ${errorText}`);
192
+ }
193
+ const text = await response.text();
194
+ const params = new URLSearchParams(text);
195
+ const accessToken = params.get('oauth_token');
196
+ const accessTokenSecret = params.get('oauth_token_secret');
197
+ const userId = params.get('user_id');
198
+ const screenName = params.get('screen_name');
199
+ if (!accessToken || !accessTokenSecret) {
200
+ throw new Error('Invalid response from access_token endpoint');
201
+ }
202
+ return {
203
+ accessToken,
204
+ accessTokenSecret,
205
+ userId: userId || '',
206
+ screenName: screenName || '',
207
+ };
208
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { postTweet, deleteTweet, verifyCredentials, getOAuthRequestToken, getOAuthAccessToken, } from './x-api.js';
3
+ describe('x-api', () => {
4
+ describe('exports', () => {
5
+ it('exports postTweet function', () => {
6
+ expect(typeof postTweet).toBe('function');
7
+ });
8
+ it('exports deleteTweet function', () => {
9
+ expect(typeof deleteTweet).toBe('function');
10
+ });
11
+ it('exports verifyCredentials function', () => {
12
+ expect(typeof verifyCredentials).toBe('function');
13
+ });
14
+ it('exports getOAuthRequestToken function', () => {
15
+ expect(typeof getOAuthRequestToken).toBe('function');
16
+ });
17
+ it('exports getOAuthAccessToken function', () => {
18
+ expect(typeof getOAuthAccessToken).toBe('function');
19
+ });
20
+ });
21
+ // Note: Integration tests would require actual X API credentials
22
+ // and are not included in unit tests to avoid rate limits and auth issues
23
+ });
package/dist/tui.js CHANGED
@@ -3,7 +3,7 @@ import { useState, useEffect, useMemo } from 'react';
3
3
  import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
4
4
  import Link from 'ink-link';
5
5
  import fs from 'fs';
6
- import { getContentDirs, parseContent, updateStatus as updateStatusLib, moveToPosts as moveToPostsLib, moveToDrafts, addPublishedUrl, deleteDraft, } from './lib/content.js';
6
+ import { getContentDirs, parseContent, updateStatus as updateStatusLib, moveToPosts as moveToPostsLib, addPublishedUrl, deleteDraft, } from './lib/content.js';
7
7
  /** Month abbreviations for date display */
8
8
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
9
9
  /**
@@ -134,9 +134,9 @@ function Preview({ item, width, height, isPosts, }) {
134
134
  })(), item.published && item.published.startsWith('http') ? (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "\u2713 " }), _jsx(Link, { url: item.published, fallback: false, children: _jsx(Text, { color: "cyan", children: item.published.slice(0, contentWidth) }) })] })) : (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u25CB " }), _jsx(Text, { color: "gray", children: "not published" })] }))] }), _jsx(Text, { color: "gray", children: '─'.repeat(contentWidth) }), _jsx(Box, { children: (() => {
135
135
  if (isPosts) {
136
136
  if (item.published?.startsWith('http')) {
137
- return _jsx(Text, { color: "gray", children: "Space: unpost" });
137
+ return _jsx(Text, { color: "gray", children: " " });
138
138
  }
139
- return _jsx(Text, { color: "gray", children: "Enter: add URL \u2502 Space: unpost" });
139
+ return _jsx(Text, { color: "gray", children: "Enter: add URL" });
140
140
  }
141
141
  return (_jsxs(Text, { color: "gray", children: ["Enter: toggle ready \u2502 Space: post", !isReady ? ' │ Backspace: delete' : ''] }));
142
142
  })() })] }));
@@ -341,19 +341,12 @@ function App() {
341
341
  setUrlInput('');
342
342
  }
343
343
  }
344
- // Space: move draft to posts, or unpost post
345
- if (input === ' ' && selected) {
346
- if (tab === 'drafts') {
347
- moveToPostsLib(selected);
348
- setTab('posts');
349
- setSelectedIndex(0);
350
- setRefreshKey((r) => r + 1);
351
- }
352
- else {
353
- moveToDrafts(selected);
354
- setSelectedIndex(0);
355
- setRefreshKey((r) => r + 1);
356
- }
344
+ // Space: move draft to posts
345
+ if (input === ' ' && selected && tab === 'drafts') {
346
+ moveToPostsLib(selected);
347
+ setTab('posts');
348
+ setSelectedIndex(0);
349
+ setRefreshKey((r) => r + 1);
357
350
  }
358
351
  // Backspace/Delete: delete in-progress draft
359
352
  if ((key.backspace || key.delete) &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noslop",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for AI assistants to manage social media posts as markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,10 @@
32
32
  "typecheck": "tsc --noEmit",
33
33
  "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test",
34
34
  "clean": "rm -rf dist",
35
+ "release": "pnpm check && pnpm release:patch",
36
+ "release:patch": "npm version patch && git push && git push --tags && gh release create v$(node -p \"require('./package.json').version\") --generate-notes",
37
+ "release:minor": "npm version minor && git push && git push --tags && gh release create v$(node -p \"require('./package.json').version\") --generate-notes",
38
+ "release:major": "npm version major && git push && git push --tags && gh release create v$(node -p \"require('./package.json').version\") --generate-notes",
35
39
  "prepublishOnly": "npm run build",
36
40
  "prepare": "npm run build"
37
41
  },
@@ -75,7 +79,7 @@
75
79
  "license": "MIT",
76
80
  "repository": {
77
81
  "type": "git",
78
- "url": "git+https://github.com/rubenartus/noslop.git"
82
+ "url": "https://github.com/rubenartus/noslop"
79
83
  },
80
84
  "bugs": {
81
85
  "url": "https://github.com/rubenartus/noslop/issues"