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.
- package/README.md +84 -4
- package/dist/index.js +321 -48
- package/dist/lib/config.d.ts +76 -0
- package/dist/lib/config.js +188 -0
- package/dist/lib/config.test.d.ts +1 -0
- package/dist/lib/config.test.js +226 -0
- package/dist/lib/content.d.ts +0 -7
- package/dist/lib/content.js +0 -42
- package/dist/lib/content.test.js +1 -54
- package/dist/lib/templates.d.ts +2 -2
- package/dist/lib/templates.js +15 -9
- package/dist/lib/x-api.d.ts +49 -0
- package/dist/lib/x-api.js +208 -0
- package/dist/lib/x-api.test.d.ts +1 -0
- package/dist/lib/x-api.test.js +23 -0
- package/dist/tui.js +9 -16
- package/package.json +6 -2
|
@@ -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,
|
|
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: "
|
|
137
|
+
return _jsx(Text, { color: "gray", children: " " });
|
|
138
138
|
}
|
|
139
|
-
return _jsx(Text, { color: "gray", children: "Enter: add URL
|
|
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
|
|
345
|
-
if (input === ' ' && selected) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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.
|
|
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": "
|
|
82
|
+
"url": "https://github.com/rubenartus/noslop"
|
|
79
83
|
},
|
|
80
84
|
"bugs": {
|
|
81
85
|
"url": "https://github.com/rubenartus/noslop/issues"
|