@posthero/cli 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/LICENSE +21 -0
- package/bin/posthero.js +4 -0
- package/package.json +37 -0
- package/src/api.js +44 -0
- package/src/commands/accounts.js +33 -0
- package/src/commands/login.js +72 -0
- package/src/commands/media.js +62 -0
- package/src/commands/posts.js +285 -0
- package/src/config.js +28 -0
- package/src/index.js +124 -0
- package/src/output.js +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 a007mr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/bin/posthero.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@posthero/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "PostHero CLI — create, schedule, and publish social media posts from the terminal",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "PostHero",
|
|
7
|
+
"homepage": "https://posthero.ai",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/a007mr/posthero-cli.git"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"posthero": "./bin/posthero.js"
|
|
17
|
+
},
|
|
18
|
+
"main": "./src/index.js",
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"src",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"dev": "node bin/posthero.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"axios": "^1.7.9",
|
|
29
|
+
"chalk": "^4.1.2",
|
|
30
|
+
"cli-table3": "^0.6.5",
|
|
31
|
+
"commander": "^12.1.0",
|
|
32
|
+
"conf": "^10.2.0",
|
|
33
|
+
"form-data": "^4.0.1",
|
|
34
|
+
"inquirer": "^8.2.6",
|
|
35
|
+
"ora": "^5.4.1"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
const { getConfig } = require('./config');
|
|
5
|
+
|
|
6
|
+
function resolveApiKey(cmdOptions = {}) {
|
|
7
|
+
return cmdOptions.key || process.env.POSTHERO_API_KEY || getConfig().apiKey;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function createClient(cmdOptions = {}) {
|
|
11
|
+
const key = resolveApiKey(cmdOptions);
|
|
12
|
+
|
|
13
|
+
if (!key) {
|
|
14
|
+
const chalk = require('chalk');
|
|
15
|
+
console.error(chalk.red('No API key found. Run: posthero login'));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { baseUrl } = getConfig();
|
|
20
|
+
|
|
21
|
+
return axios.create({
|
|
22
|
+
baseURL: baseUrl,
|
|
23
|
+
headers: {
|
|
24
|
+
Authorization: `Bearer ${key}`,
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function apiCall(fn) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fn();
|
|
33
|
+
return res.data;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
const data = err.response?.data;
|
|
36
|
+
const msg = data?.error || err.message;
|
|
37
|
+
const code = data?.code || 'UNKNOWN';
|
|
38
|
+
const chalk = require('chalk');
|
|
39
|
+
console.error(chalk.red(`Error [${code}]: ${msg}`));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { createClient, apiCall, resolveApiKey };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const { createClient, apiCall } = require('../api');
|
|
5
|
+
const { isJsonMode, printJson, printTable } = require('../output');
|
|
6
|
+
|
|
7
|
+
async function list(options) {
|
|
8
|
+
const client = createClient(options);
|
|
9
|
+
const result = await apiCall(() => client.get('/accounts'));
|
|
10
|
+
|
|
11
|
+
if (isJsonMode(options)) {
|
|
12
|
+
printJson(result.data);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!result.data.length) {
|
|
17
|
+
console.log(chalk.grey('No connected accounts found.'));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
printTable(
|
|
22
|
+
['ID', 'Platform', 'Name', 'Username', 'Type'],
|
|
23
|
+
result.data.map(a => [
|
|
24
|
+
chalk.grey(String(a.id)),
|
|
25
|
+
chalk.bold(a.platform),
|
|
26
|
+
a.name || '—',
|
|
27
|
+
a.username ? '@' + a.username : '—',
|
|
28
|
+
a.type || '—',
|
|
29
|
+
])
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { list };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const inquirer = require('inquirer');
|
|
4
|
+
const ora = require('ora');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const { setApiKey, clearApiKey } = require('../config');
|
|
7
|
+
const { createClient, apiCall } = require('../api');
|
|
8
|
+
const { maskKey } = require('../output');
|
|
9
|
+
|
|
10
|
+
async function login(options) {
|
|
11
|
+
let key = options.key;
|
|
12
|
+
|
|
13
|
+
if (!key) {
|
|
14
|
+
const { inputKey } = await inquirer.prompt([
|
|
15
|
+
{
|
|
16
|
+
type: 'password',
|
|
17
|
+
name: 'inputKey',
|
|
18
|
+
message: 'Paste your API key (from posthero.ai/app/settings/api):',
|
|
19
|
+
validate: v => v.startsWith('pk_') ? true : 'Key must start with pk_',
|
|
20
|
+
},
|
|
21
|
+
]);
|
|
22
|
+
key = inputKey;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const spinner = ora('Validating API key...').start();
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const client = createClient({ key });
|
|
29
|
+
const result = await apiCall(() => client.get('/accounts'));
|
|
30
|
+
|
|
31
|
+
if (result.success) {
|
|
32
|
+
setApiKey(key);
|
|
33
|
+
spinner.succeed(`Logged in! Key saved: ${maskKey(key)}`);
|
|
34
|
+
console.log(chalk.grey(` Found ${result.data.length} connected account(s).`));
|
|
35
|
+
} else {
|
|
36
|
+
spinner.fail('Invalid API key.');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
spinner.fail('Could not validate key.');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function logout() {
|
|
46
|
+
clearApiKey();
|
|
47
|
+
console.log(chalk.green('✓') + ' Logged out. API key removed.');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function whoami(options) {
|
|
51
|
+
const { createClient, apiCall, resolveApiKey } = require('../api');
|
|
52
|
+
const { maskKey } = require('../output');
|
|
53
|
+
|
|
54
|
+
const key = resolveApiKey(options);
|
|
55
|
+
if (!key) {
|
|
56
|
+
console.error(chalk.red('Not logged in. Run: posthero login'));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const spinner = ora('Fetching info...').start();
|
|
61
|
+
const client = createClient(options);
|
|
62
|
+
|
|
63
|
+
const result = await apiCall(() => client.get('/accounts'));
|
|
64
|
+
spinner.stop();
|
|
65
|
+
|
|
66
|
+
console.log('');
|
|
67
|
+
console.log(` API Key: ${maskKey(key)}`);
|
|
68
|
+
console.log(` Accounts: ${result.data.length} connected`);
|
|
69
|
+
console.log('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { login, logout, whoami };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const ora = require('ora');
|
|
6
|
+
const FormData = require('form-data');
|
|
7
|
+
const { createClient, resolveApiKey, apiCall } = require('../api');
|
|
8
|
+
const { getConfig } = require('../config');
|
|
9
|
+
const { isJsonMode, printJson, printSuccess } = require('../output');
|
|
10
|
+
|
|
11
|
+
// Returns the S3 URL — used internally by posts.js too
|
|
12
|
+
async function upload(filePath, options = {}) {
|
|
13
|
+
const resolved = path.resolve(filePath);
|
|
14
|
+
|
|
15
|
+
if (!fs.existsSync(resolved)) {
|
|
16
|
+
const chalk = require('chalk');
|
|
17
|
+
console.error(chalk.red(`File not found: ${resolved}`));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const key = resolveApiKey(options);
|
|
22
|
+
const { baseUrl } = getConfig();
|
|
23
|
+
|
|
24
|
+
const form = new FormData();
|
|
25
|
+
form.append('file', fs.createReadStream(resolved));
|
|
26
|
+
|
|
27
|
+
const spinner = ora(`Uploading ${path.basename(resolved)}...`).start();
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const axios = require('axios');
|
|
31
|
+
const res = await axios.post(`${baseUrl}/media/upload`, form, {
|
|
32
|
+
headers: {
|
|
33
|
+
Authorization: `Bearer ${key}`,
|
|
34
|
+
...form.getHeaders(),
|
|
35
|
+
},
|
|
36
|
+
maxContentLength: Infinity,
|
|
37
|
+
maxBodyLength: Infinity,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
spinner.stop();
|
|
41
|
+
return res.data?.data?.url;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
spinner.fail('Upload failed.');
|
|
44
|
+
const chalk = require('chalk');
|
|
45
|
+
const msg = err.response?.data?.error || err.message;
|
|
46
|
+
console.error(chalk.red(msg));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function uploadCommand(filePath, options) {
|
|
52
|
+
const url = await upload(filePath, options);
|
|
53
|
+
|
|
54
|
+
if (isJsonMode(options)) {
|
|
55
|
+
printJson({ url });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
printSuccess(`Uploaded: ${url}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { upload, uploadCommand };
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const inquirer = require('inquirer');
|
|
6
|
+
const ora = require('ora');
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
const { createClient, apiCall } = require('../api');
|
|
9
|
+
const { isJsonMode, printJson, printTable, printSuccess, formatDate } = require('../output');
|
|
10
|
+
|
|
11
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function statusColor(status) {
|
|
14
|
+
switch (status) {
|
|
15
|
+
case 'published': return chalk.green(status);
|
|
16
|
+
case 'scheduled': return chalk.yellow(status);
|
|
17
|
+
case 'draft': return chalk.grey(status);
|
|
18
|
+
case 'failed': return chalk.red(status);
|
|
19
|
+
default: return status;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Build the POST /posts request body from CLI options + optionally a media URL
|
|
24
|
+
function buildPostBody(options, mediaUrl) {
|
|
25
|
+
let text = options.text || '';
|
|
26
|
+
|
|
27
|
+
if (options.file) {
|
|
28
|
+
text = fs.readFileSync(path.resolve(options.file), 'utf-8').trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Parse platforms string e.g. "linkedin,twitter" → array
|
|
32
|
+
// Each entry needs platform + accountId.
|
|
33
|
+
// User can pass: "linkedin:accountId123,twitter:accountId456"
|
|
34
|
+
// or just "linkedin,twitter" (server resolves first matching account per platform)
|
|
35
|
+
const parsedPlatforms = (options.platforms || '').split(',').filter(Boolean).map(p => {
|
|
36
|
+
const [platform, accountId] = p.trim().split(':');
|
|
37
|
+
return accountId ? { platform, accountId } : { platform };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Per-platform text overrides
|
|
41
|
+
const platformContent = {};
|
|
42
|
+
if (options.linkedinText) platformContent.linkedin = { text: options.linkedinText };
|
|
43
|
+
if (options.twitterText) platformContent.twitter = { text: options.twitterText };
|
|
44
|
+
if (options.blueskyText) platformContent.bluesky = { text: options.blueskyText };
|
|
45
|
+
if (options.threadsText) platformContent.threads = { text: options.threadsText };
|
|
46
|
+
|
|
47
|
+
const body = {
|
|
48
|
+
text,
|
|
49
|
+
platforms: parsedPlatforms,
|
|
50
|
+
publishNow: options.now || false,
|
|
51
|
+
schedule: options.schedule || undefined,
|
|
52
|
+
isThread: options.thread || false,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (Object.keys(platformContent).length) body.platformContent = platformContent;
|
|
56
|
+
|
|
57
|
+
if (mediaUrl) {
|
|
58
|
+
body.media = { images: [mediaUrl] };
|
|
59
|
+
} else if (options.video) {
|
|
60
|
+
// video handled separately via upload first
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return body;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── commands ─────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async function list(options) {
|
|
69
|
+
const client = createClient(options);
|
|
70
|
+
|
|
71
|
+
const params = {};
|
|
72
|
+
if (options.status) params.status = options.status;
|
|
73
|
+
if (options.platform) params.platform = options.platform;
|
|
74
|
+
if (options.page) params.page = options.page;
|
|
75
|
+
if (options.limit) params.limit = options.limit;
|
|
76
|
+
|
|
77
|
+
const result = await apiCall(() => client.get('/posts', { params }));
|
|
78
|
+
|
|
79
|
+
if (isJsonMode(options)) {
|
|
80
|
+
printJson(result.data);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { posts, pagination } = result.data;
|
|
85
|
+
|
|
86
|
+
if (!posts.length) {
|
|
87
|
+
console.log(chalk.grey('No posts found.'));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
printTable(
|
|
92
|
+
['ID', 'Status', 'Platforms', 'Scheduled At', 'Created At'],
|
|
93
|
+
posts.map(p => [
|
|
94
|
+
chalk.grey(p.id),
|
|
95
|
+
statusColor(p.status),
|
|
96
|
+
(p.platforms || []).map(x => x.platform).join(', ') || '—',
|
|
97
|
+
formatDate(p.schedule),
|
|
98
|
+
formatDate(p.createdAt),
|
|
99
|
+
])
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (pagination) {
|
|
103
|
+
console.log(chalk.grey(`\n Page ${pagination.page} of ${Math.ceil(pagination.total / pagination.limit)} — ${pagination.total} total posts`));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function get(id, options) {
|
|
108
|
+
const client = createClient(options);
|
|
109
|
+
const result = await apiCall(() => client.get(`/posts/${id}`));
|
|
110
|
+
|
|
111
|
+
if (isJsonMode(options)) {
|
|
112
|
+
printJson(result.data);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const p = result.data;
|
|
117
|
+
console.log('');
|
|
118
|
+
console.log(` ID: ${chalk.grey(p.id)}`);
|
|
119
|
+
console.log(` Status: ${statusColor(p.status)}`);
|
|
120
|
+
console.log(` Text: ${p.text?.slice(0, 120)}${p.text?.length > 120 ? '…' : ''}`);
|
|
121
|
+
console.log(` Platforms: ${(p.platforms || []).map(x => x.platform).join(', ') || '—'}`);
|
|
122
|
+
console.log(` Scheduled: ${formatDate(p.schedule)}`);
|
|
123
|
+
console.log(` Created: ${formatDate(p.createdAt)}`);
|
|
124
|
+
|
|
125
|
+
if (p.platforms?.length) {
|
|
126
|
+
console.log('');
|
|
127
|
+
p.platforms.forEach(x => {
|
|
128
|
+
const url = x.postUrl ? chalk.cyan(x.postUrl) : '—';
|
|
129
|
+
console.log(` [${x.platform}] ${statusColor(x.status)} ${url}`);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
console.log('');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function create(options) {
|
|
136
|
+
let finalOptions = { ...options };
|
|
137
|
+
|
|
138
|
+
// Interactive mode when no text and no file
|
|
139
|
+
if (!options.text && !options.file && !options.interactive) {
|
|
140
|
+
const client = createClient(options);
|
|
141
|
+
const accountsResult = await apiCall(() => client.get('/accounts'));
|
|
142
|
+
const accountChoices = accountsResult.data.map(a => ({
|
|
143
|
+
name: `${a.platform} — ${a.name || a.username || a.id}`,
|
|
144
|
+
value: `${a.platform}:${a.id}`,
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
const answers = await inquirer.prompt([
|
|
148
|
+
{
|
|
149
|
+
type: 'editor',
|
|
150
|
+
name: 'text',
|
|
151
|
+
message: 'Post content:',
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
type: 'checkbox',
|
|
155
|
+
name: 'platformsList',
|
|
156
|
+
message: 'Publish to:',
|
|
157
|
+
choices: accountChoices,
|
|
158
|
+
validate: v => v.length ? true : 'Select at least one platform',
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
type: 'list',
|
|
162
|
+
name: 'action',
|
|
163
|
+
message: 'What to do:',
|
|
164
|
+
choices: [
|
|
165
|
+
{ name: 'Save as draft', value: 'draft' },
|
|
166
|
+
{ name: 'Publish now', value: 'now' },
|
|
167
|
+
{ name: 'Schedule for later', value: 'schedule' },
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
type: 'input',
|
|
172
|
+
name: 'schedule',
|
|
173
|
+
message: 'Schedule time (ISO 8601, e.g. 2025-04-01T09:00:00Z):',
|
|
174
|
+
when: a => a.action === 'schedule',
|
|
175
|
+
},
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
finalOptions.text = answers.text.trim();
|
|
179
|
+
finalOptions.platforms = answers.platformsList.join(',');
|
|
180
|
+
finalOptions.now = answers.action === 'now';
|
|
181
|
+
finalOptions.schedule = answers.schedule || undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Upload image first if --image flag provided
|
|
185
|
+
let mediaUrl;
|
|
186
|
+
if (finalOptions.image) {
|
|
187
|
+
const { upload } = require('./media');
|
|
188
|
+
mediaUrl = await upload(finalOptions.image, finalOptions);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const body = buildPostBody(finalOptions, mediaUrl);
|
|
192
|
+
|
|
193
|
+
if (!body.text && !body.platformContent) {
|
|
194
|
+
console.error(chalk.red('Post text is required (--text or --file)'));
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!body.platforms.length) {
|
|
199
|
+
console.error(chalk.red('At least one platform is required (--platforms linkedin,twitter)'));
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const spinner = ora('Creating post...').start();
|
|
204
|
+
const client = createClient(finalOptions);
|
|
205
|
+
const result = await apiCall(() => client.post('/posts', body));
|
|
206
|
+
spinner.stop();
|
|
207
|
+
|
|
208
|
+
if (isJsonMode(options)) {
|
|
209
|
+
printJson(result.data);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
printSuccess(`Post ${result.data.status}: ${chalk.grey(result.data.id)}`);
|
|
214
|
+
if (result.data.schedule) {
|
|
215
|
+
console.log(chalk.grey(` Scheduled for: ${formatDate(result.data.schedule)}`));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function update(id, options) {
|
|
220
|
+
const body = {};
|
|
221
|
+
if (options.text) body.text = options.text;
|
|
222
|
+
if (options.schedule) body.schedule = options.schedule;
|
|
223
|
+
if (options.platforms) {
|
|
224
|
+
body.platforms = options.platforms.split(',').filter(Boolean).map(p => {
|
|
225
|
+
const [platform, accountId] = p.trim().split(':');
|
|
226
|
+
return accountId ? { platform, accountId } : { platform };
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const spinner = ora('Updating post...').start();
|
|
231
|
+
const client = createClient(options);
|
|
232
|
+
const result = await apiCall(() => client.patch(`/posts/${id}`, body));
|
|
233
|
+
spinner.stop();
|
|
234
|
+
|
|
235
|
+
if (isJsonMode(options)) {
|
|
236
|
+
printJson(result.data);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
printSuccess(`Post updated: ${chalk.grey(id)}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function del(id, options) {
|
|
244
|
+
if (!options.force) {
|
|
245
|
+
const { confirm } = await inquirer.prompt([
|
|
246
|
+
{
|
|
247
|
+
type: 'confirm',
|
|
248
|
+
name: 'confirm',
|
|
249
|
+
message: `Delete post ${id}?`,
|
|
250
|
+
default: false,
|
|
251
|
+
},
|
|
252
|
+
]);
|
|
253
|
+
if (!confirm) {
|
|
254
|
+
console.log(chalk.grey('Cancelled.'));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const spinner = ora('Deleting...').start();
|
|
260
|
+
const client = createClient(options);
|
|
261
|
+
await apiCall(() => client.delete(`/posts/${id}`));
|
|
262
|
+
spinner.stop();
|
|
263
|
+
|
|
264
|
+
printSuccess(`Post deleted: ${chalk.grey(id)}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function publish(id, options) {
|
|
268
|
+
const spinner = ora('Publishing...').start();
|
|
269
|
+
const client = createClient(options);
|
|
270
|
+
const result = await apiCall(() => client.post(`/posts/${id}/publish`));
|
|
271
|
+
spinner.stop();
|
|
272
|
+
|
|
273
|
+
if (isJsonMode(options)) {
|
|
274
|
+
printJson(result.data);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
printSuccess(`Published: ${chalk.grey(id)}`);
|
|
279
|
+
(result.data.results || []).forEach(r => {
|
|
280
|
+
const url = r.postUrl ? chalk.cyan(r.postUrl) : '';
|
|
281
|
+
console.log(` [${r.platform}] ${statusColor(r.status)} ${url}`);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = { list, get, create, update, delete: del, publish };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Conf = require('conf');
|
|
4
|
+
|
|
5
|
+
const store = new Conf({
|
|
6
|
+
projectName: 'posthero',
|
|
7
|
+
schema: {
|
|
8
|
+
apiKey: { type: 'string' },
|
|
9
|
+
baseUrl: { type: 'string' },
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function getConfig() {
|
|
14
|
+
return {
|
|
15
|
+
apiKey: store.get('apiKey'),
|
|
16
|
+
baseUrl: process.env.POSTHERO_BASE_URL || store.get('baseUrl') || 'https://server.posthero.ai/api/v1',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function setApiKey(key) {
|
|
21
|
+
store.set('apiKey', key);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clearApiKey() {
|
|
25
|
+
store.delete('apiKey');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { getConfig, setApiKey, clearApiKey };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const pkg = require('../package.json');
|
|
5
|
+
|
|
6
|
+
const loginCmd = require('./commands/login');
|
|
7
|
+
const accountsCmd = require('./commands/accounts');
|
|
8
|
+
const postsCmd = require('./commands/posts');
|
|
9
|
+
const mediaCmd = require('./commands/media');
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('posthero')
|
|
15
|
+
.description('PostHero CLI — create, schedule, and publish social media posts from the terminal')
|
|
16
|
+
.version(pkg.version);
|
|
17
|
+
|
|
18
|
+
// Global options available on all commands
|
|
19
|
+
const globalOptions = cmd => cmd
|
|
20
|
+
.option('--key <key>', 'API key (overrides stored key and POSTHERO_API_KEY env var)')
|
|
21
|
+
.option('--json', 'Output raw JSON (for scripting)');
|
|
22
|
+
|
|
23
|
+
// ── auth ─────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
program
|
|
26
|
+
.command('login')
|
|
27
|
+
.description('Save your API key locally')
|
|
28
|
+
.option('--key <key>', 'API key to save (non-interactive)')
|
|
29
|
+
.option('--json', 'Output raw JSON (for scripting)')
|
|
30
|
+
.action(opts => loginCmd.login(opts));
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.command('logout')
|
|
34
|
+
.description('Remove stored API key')
|
|
35
|
+
.action(() => loginCmd.logout());
|
|
36
|
+
|
|
37
|
+
globalOptions(
|
|
38
|
+
program
|
|
39
|
+
.command('whoami')
|
|
40
|
+
.description('Show current API key and account info')
|
|
41
|
+
).action(opts => loginCmd.whoami(opts));
|
|
42
|
+
|
|
43
|
+
// ── accounts ─────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const accounts = program.command('accounts').description('Manage connected social media accounts');
|
|
46
|
+
|
|
47
|
+
globalOptions(
|
|
48
|
+
accounts
|
|
49
|
+
.command('list')
|
|
50
|
+
.description('List all connected accounts')
|
|
51
|
+
).action(opts => accountsCmd.list(opts));
|
|
52
|
+
|
|
53
|
+
// ── posts ─────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const posts = program.command('posts').description('Create and manage posts');
|
|
56
|
+
|
|
57
|
+
globalOptions(
|
|
58
|
+
posts
|
|
59
|
+
.command('list')
|
|
60
|
+
.description('List posts')
|
|
61
|
+
.option('--status <status>', 'Filter by status: draft | scheduled | published | failed')
|
|
62
|
+
.option('--platform <platform>', 'Filter by platform')
|
|
63
|
+
.option('--page <n>', 'Page number', '1')
|
|
64
|
+
.option('--limit <n>', 'Posts per page', '20')
|
|
65
|
+
).action(opts => postsCmd.list(opts));
|
|
66
|
+
|
|
67
|
+
globalOptions(
|
|
68
|
+
posts
|
|
69
|
+
.command('get <id>')
|
|
70
|
+
.description('Get a single post by ID')
|
|
71
|
+
).action((id, opts) => postsCmd.get(id, opts));
|
|
72
|
+
|
|
73
|
+
globalOptions(
|
|
74
|
+
posts
|
|
75
|
+
.command('create')
|
|
76
|
+
.description('Create a post (interactive if no flags given)')
|
|
77
|
+
.option('--text <text>', 'Post text')
|
|
78
|
+
.option('--file <path>', 'Read post text from a file (markdown or plain text)')
|
|
79
|
+
.option('--platforms <list>', 'Comma-separated platforms, e.g. linkedin:id123,twitter:id456')
|
|
80
|
+
.option('--schedule <iso>', 'Schedule time in ISO 8601 UTC, e.g. 2025-04-01T09:00:00Z')
|
|
81
|
+
.option('--now', 'Publish immediately')
|
|
82
|
+
.option('--thread', 'Thread mode (split text on double line breaks)')
|
|
83
|
+
.option('--image <path>', 'Image file to attach')
|
|
84
|
+
.option('--linkedin-text <text>', 'LinkedIn-specific text override')
|
|
85
|
+
.option('--twitter-text <text>', 'Twitter-specific text override')
|
|
86
|
+
.option('--bluesky-text <text>', 'Bluesky-specific text override')
|
|
87
|
+
.option('--threads-text <text>', 'Threads-specific text override')
|
|
88
|
+
).action(opts => postsCmd.create(opts));
|
|
89
|
+
|
|
90
|
+
globalOptions(
|
|
91
|
+
posts
|
|
92
|
+
.command('update <id>')
|
|
93
|
+
.description('Update a draft or scheduled post')
|
|
94
|
+
.option('--text <text>', 'New text')
|
|
95
|
+
.option('--schedule <iso>', 'New schedule time')
|
|
96
|
+
.option('--platforms <list>', 'New platforms list')
|
|
97
|
+
).action((id, opts) => postsCmd.update(id, opts));
|
|
98
|
+
|
|
99
|
+
globalOptions(
|
|
100
|
+
posts
|
|
101
|
+
.command('delete <id>')
|
|
102
|
+
.description('Delete a post')
|
|
103
|
+
.option('--force', 'Skip confirmation prompt')
|
|
104
|
+
).action((id, opts) => postsCmd.delete(id, opts));
|
|
105
|
+
|
|
106
|
+
globalOptions(
|
|
107
|
+
posts
|
|
108
|
+
.command('publish <id>')
|
|
109
|
+
.description('Publish a draft or scheduled post immediately')
|
|
110
|
+
).action((id, opts) => postsCmd.publish(id, opts));
|
|
111
|
+
|
|
112
|
+
// ── media ─────────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const media = program.command('media').description('Upload media files');
|
|
115
|
+
|
|
116
|
+
globalOptions(
|
|
117
|
+
media
|
|
118
|
+
.command('upload <file>')
|
|
119
|
+
.description('Upload an image, video, or PDF and get back an S3 URL')
|
|
120
|
+
).action((file, opts) => mediaCmd.uploadCommand(file, opts));
|
|
121
|
+
|
|
122
|
+
// ── parse ─────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
program.parse(process.argv);
|
package/src/output.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const Table = require('cli-table3');
|
|
5
|
+
|
|
6
|
+
function isJsonMode(options = {}) {
|
|
7
|
+
return options.json === true;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function printJson(data) {
|
|
11
|
+
console.log(JSON.stringify(data, null, 2));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function printTable(head, rows) {
|
|
15
|
+
const table = new Table({
|
|
16
|
+
head: head.map(h => chalk.cyan(h)),
|
|
17
|
+
style: { border: ['grey'] },
|
|
18
|
+
});
|
|
19
|
+
rows.forEach(r => table.push(r));
|
|
20
|
+
console.log(table.toString());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function printSuccess(msg) {
|
|
24
|
+
console.log(chalk.green('✓') + ' ' + msg);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printInfo(msg) {
|
|
28
|
+
console.log(chalk.grey(msg));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function maskKey(key) {
|
|
32
|
+
if (!key || key.length < 12) return key;
|
|
33
|
+
return key.slice(0, 10) + '●'.repeat(8);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatDate(dateStr) {
|
|
37
|
+
if (!dateStr) return chalk.grey('—');
|
|
38
|
+
return new Date(dateStr).toLocaleString('en-US', {
|
|
39
|
+
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { isJsonMode, printJson, printTable, printSuccess, printInfo, maskKey, formatDate };
|