@thesethrose/socialspool-cli 1.0.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 +169 -0
- package/SKILL.md +187 -0
- package/dist/spool.js +886 -0
- package/package.json +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# @thesethrose/socialspool-cli
|
|
2
|
+
|
|
3
|
+
Agent-friendly CLI for the [SocialSpool](https://socialspool.com/) Public API.
|
|
4
|
+
|
|
5
|
+
SocialSpool is a social scheduling tool for creating posts, scheduling them
|
|
6
|
+
across connected publishing accounts, and checking final publish status. This
|
|
7
|
+
CLI is designed for humans and coding agents that need a safe, scriptable way
|
|
8
|
+
to work through the public API without using dashboard session routes.
|
|
9
|
+
|
|
10
|
+
## What You Can Do
|
|
11
|
+
|
|
12
|
+
- Inspect the authenticated workspace and API key scopes.
|
|
13
|
+
- List connected publishing accounts.
|
|
14
|
+
- Validate post content against account and platform constraints.
|
|
15
|
+
- Create drafts.
|
|
16
|
+
- Schedule posts for future publishing.
|
|
17
|
+
- Publish posts immediately.
|
|
18
|
+
- Wait for terminal publish status.
|
|
19
|
+
- Inspect timelines and failure details.
|
|
20
|
+
- Upload media assets when media support is enabled.
|
|
21
|
+
- Manage public API webhooks when the API key has webhook scopes.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g @thesethrose/socialspool-cli
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or run without a global install:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx @thesethrose/socialspool-cli doctor --json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Requires Node.js 20.19 or newer.
|
|
36
|
+
|
|
37
|
+
## Authentication
|
|
38
|
+
|
|
39
|
+
Create a SocialSpool API key in the dashboard, then set it in your shell or
|
|
40
|
+
agent runtime:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
export SOCIALSPOOL_API_KEY=ssp_live_...
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For scheduling agents, use these scopes:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
posts:read
|
|
50
|
+
posts:write
|
|
51
|
+
accounts:read
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Add `webhooks:read` and `webhooks:write` only when the agent should manage
|
|
55
|
+
webhooks.
|
|
56
|
+
|
|
57
|
+
The default API base URL is:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
https://socialspool.com/api/v1
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Override it with:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
export SOCIALSPOOL_API_BASE_URL=https://socialspool.com/api/v1
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
or pass `--base-url`.
|
|
70
|
+
|
|
71
|
+
Never paste API keys into prompts, logs, tickets, or generated files. Configure
|
|
72
|
+
them through the runtime environment.
|
|
73
|
+
|
|
74
|
+
## Verify Setup
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
spool me --json
|
|
78
|
+
spool accounts list --json
|
|
79
|
+
spool capabilities --json
|
|
80
|
+
spool doctor --json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`doctor` checks authentication and core API reachability. If it fails, the JSON
|
|
84
|
+
output includes the structured error message and request id when the API
|
|
85
|
+
returned one.
|
|
86
|
+
|
|
87
|
+
## Common commands
|
|
88
|
+
|
|
89
|
+
List connected accounts:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
spool accounts list --json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Validate content before scheduling:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
spool posts validate \
|
|
99
|
+
--content "Hello from SocialSpool" \
|
|
100
|
+
--account acct_123 \
|
|
101
|
+
--json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Create and schedule a post:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
spool posts create \
|
|
108
|
+
--content "Hello from SocialSpool" \
|
|
109
|
+
--account acct_123 \
|
|
110
|
+
--publish-at 2026-06-12T15:00:00.000Z \
|
|
111
|
+
--idempotency-key schedule-launch-post-20260612 \
|
|
112
|
+
--json
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Wait for final status:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
spool posts wait post_123 --json
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Publish an existing draft now:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
spool posts publish-now post_123 \
|
|
125
|
+
--account acct_123 \
|
|
126
|
+
--idempotency-key publish-now-post-123 \
|
|
127
|
+
--json
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Cancel a scheduled post:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
spool posts cancel post_123 --json
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Inspect a post timeline:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
spool posts timeline post_123 --json
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Status Semantics
|
|
143
|
+
|
|
144
|
+
Do not treat a successful request as a successful platform publish.
|
|
145
|
+
|
|
146
|
+
- `draft`: saved but not scheduled.
|
|
147
|
+
- `scheduled`: accepted for future publishing.
|
|
148
|
+
- `publishing`: worker is attempting to publish.
|
|
149
|
+
- `published`: confirmed by the platform adapter.
|
|
150
|
+
- `failed`: terminal failure with an error code/message.
|
|
151
|
+
- `canceled`: scheduled publish was canceled.
|
|
152
|
+
|
|
153
|
+
Only report a post as published when SocialSpool returns `published` and
|
|
154
|
+
platform result data such as a platform post id or URL.
|
|
155
|
+
|
|
156
|
+
## Agent skill
|
|
157
|
+
|
|
158
|
+
This package includes `SKILL.md`, the SocialSpool Public API Agent skill. Give
|
|
159
|
+
that file to compatible coding agents along with an API key configured in their
|
|
160
|
+
runtime environment.
|
|
161
|
+
|
|
162
|
+
The skill intentionally excludes admin/customer-support operations. It uses the
|
|
163
|
+
public `spool` CLI only.
|
|
164
|
+
|
|
165
|
+
## Links
|
|
166
|
+
|
|
167
|
+
- Website: [https://socialspool.com/](https://socialspool.com/)
|
|
168
|
+
- API base URL: `https://socialspool.com/api/v1`
|
|
169
|
+
- OpenAPI contract: `https://socialspool.com/api/docs/openapi`
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: socialspool-public-api-agent
|
|
3
|
+
description: Use SocialSpool to inspect a workspace, list connected publishing accounts, create drafts, schedule posts, publish immediately, cancel scheduled posts, and verify post status through the public API and spool CLI.
|
|
4
|
+
version: 2.0.0
|
|
5
|
+
author: SocialSpool
|
|
6
|
+
metadata:
|
|
7
|
+
hermes:
|
|
8
|
+
tags: [socialspool, spool, cli, social-media, api, scheduling, agents]
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Skill: SocialSpool Public API Agent
|
|
12
|
+
|
|
13
|
+
## Purpose
|
|
14
|
+
|
|
15
|
+
Use SocialSpool to inspect a workspace, list connected publishing accounts, create drafts, schedule posts, publish immediately, cancel scheduled posts, and verify post status.
|
|
16
|
+
|
|
17
|
+
## When to use
|
|
18
|
+
|
|
19
|
+
Use this skill when a user asks an agent to create, schedule, publish, cancel, or inspect SocialSpool posts through the public API.
|
|
20
|
+
|
|
21
|
+
## Do not use this skill for
|
|
22
|
+
|
|
23
|
+
- Admin/customer-support operations, audit inspection, operations queues, or billing diagnostics (use `spool-admin` and `src/cli/spool-admin/SKILL.md` instead)
|
|
24
|
+
- User suspension/reactivation
|
|
25
|
+
- Workspace deletion
|
|
26
|
+
- Billing repair
|
|
27
|
+
- API key creation/revocation
|
|
28
|
+
- Direct database access
|
|
29
|
+
- Dashboard session-only routes
|
|
30
|
+
- Social OAuth connection flows
|
|
31
|
+
- Reading or handling social platform tokens
|
|
32
|
+
- Claiming platform publish success before SocialSpool reports confirmed platform result data
|
|
33
|
+
|
|
34
|
+
## Authentication
|
|
35
|
+
|
|
36
|
+
- Use `SOCIALSPOOL_API_KEY`.
|
|
37
|
+
- Optional override: `SOCIALSPOOL_API_BASE_URL` or `--base-url`.
|
|
38
|
+
- Never print, log, summarize, or echo the API key.
|
|
39
|
+
- If authentication fails, report the structured API error code and request id if present.
|
|
40
|
+
|
|
41
|
+
## Required first checks
|
|
42
|
+
|
|
43
|
+
Every agent workflow must start with:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
spool me --json
|
|
47
|
+
spool accounts list --json
|
|
48
|
+
spool capabilities --json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Core workflow: schedule a post
|
|
52
|
+
|
|
53
|
+
1. Inspect workspace and scopes.
|
|
54
|
+
2. List connected accounts.
|
|
55
|
+
3. Validate content against selected account/platform constraints.
|
|
56
|
+
4. Create a draft or schedule directly.
|
|
57
|
+
5. Use an idempotency key for every write.
|
|
58
|
+
6. Verify returned post/target status.
|
|
59
|
+
7. Poll/wait until status is terminal when the user asked for confirmation.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
spool accounts list --json
|
|
65
|
+
|
|
66
|
+
spool posts validate \
|
|
67
|
+
--content "Post content here" \
|
|
68
|
+
--account acct_123 \
|
|
69
|
+
--publish-at 2026-06-12T15:00:00.000Z \
|
|
70
|
+
--json
|
|
71
|
+
|
|
72
|
+
spool posts create \
|
|
73
|
+
--content "Post content here" \
|
|
74
|
+
--account acct_123 \
|
|
75
|
+
--publish-at 2026-06-12T15:00:00.000Z \
|
|
76
|
+
--idempotency-key schedule-<stable-operation-id> \
|
|
77
|
+
--json
|
|
78
|
+
|
|
79
|
+
spool posts wait post_123 --json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Core workflow: publish now
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
spool posts create --content "Post content here" --json
|
|
86
|
+
|
|
87
|
+
spool posts publish-now post_123 \
|
|
88
|
+
--account acct_123 \
|
|
89
|
+
--idempotency-key publish-now-<stable-operation-id> \
|
|
90
|
+
--json
|
|
91
|
+
|
|
92
|
+
spool posts wait post_123 --json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Core workflow: cancel scheduled post
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
spool posts cancel post_123 --json
|
|
99
|
+
spool posts get post_123 --json
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Media workflow
|
|
103
|
+
|
|
104
|
+
When attaching media, upload first, then reference returned media asset ids:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
spool media config --json
|
|
108
|
+
spool media upload ./image.png --json
|
|
109
|
+
spool posts create --post-style text_media --content "Caption" --media-asset-ids media_123 --json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Status semantics
|
|
113
|
+
|
|
114
|
+
- `draft`: not scheduled, not published.
|
|
115
|
+
- `scheduled`: accepted for future publishing.
|
|
116
|
+
- `publishing`: worker is attempting to publish.
|
|
117
|
+
- `published`: confirmed by platform adapter. Only this may be reported as published.
|
|
118
|
+
- `failed`: terminal failure. Report error code/message.
|
|
119
|
+
- `canceled`: no platform publish should occur.
|
|
120
|
+
|
|
121
|
+
## Hard rules
|
|
122
|
+
|
|
123
|
+
- Never claim publish success from a 200/202 response alone.
|
|
124
|
+
- Never claim success from `scheduled`, `queued`, or `publishing`.
|
|
125
|
+
- A post is published only when SocialSpool returns `published` plus platform result data such as platform post id or URL.
|
|
126
|
+
- Use idempotency keys for every POST/PATCH/DELETE.
|
|
127
|
+
- Retry only through public API/CLI commands.
|
|
128
|
+
- Do not call admin routes.
|
|
129
|
+
- Do not ask the user for social tokens.
|
|
130
|
+
- Do not use dashboard-only endpoints.
|
|
131
|
+
- Prefer JSON output.
|
|
132
|
+
|
|
133
|
+
## Troubleshooting
|
|
134
|
+
|
|
135
|
+
- Use `spool posts get <postId> --json`.
|
|
136
|
+
- Use `spool posts timeline <postId> --json`.
|
|
137
|
+
- For cross-workspace audit, billing, or operations investigation, use `spool-admin` instead of `spool`.
|
|
138
|
+
|
|
139
|
+
## Expected agent response
|
|
140
|
+
|
|
141
|
+
Return:
|
|
142
|
+
|
|
143
|
+
- command(s) run
|
|
144
|
+
- post id
|
|
145
|
+
- selected account ids/platforms
|
|
146
|
+
- idempotency key used
|
|
147
|
+
- final status
|
|
148
|
+
- platform URL/id when published
|
|
149
|
+
- error code/message when failed
|
|
150
|
+
- next required action if blocked
|
|
151
|
+
|
|
152
|
+
## CLI reference
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
spool me --json
|
|
156
|
+
spool capabilities --json
|
|
157
|
+
spool doctor --json
|
|
158
|
+
spool openapi --json
|
|
159
|
+
spool accounts list --json
|
|
160
|
+
spool posts validate --content "..." --account acct_1 --json
|
|
161
|
+
spool posts list --json
|
|
162
|
+
spool posts get post_123 --json
|
|
163
|
+
spool posts create --content "..." --json
|
|
164
|
+
spool posts schedule post_123 --account acct_1 --publish-at 2026-06-12T15:00:00.000Z --json
|
|
165
|
+
spool posts publish-now post_123 --account acct_1 --json
|
|
166
|
+
spool posts cancel post_123 --json
|
|
167
|
+
spool posts wait post_123 --json
|
|
168
|
+
spool posts timeline post_123 --json
|
|
169
|
+
spool media config --json
|
|
170
|
+
spool media upload ./image.png --json
|
|
171
|
+
spool media delete media_123 --json
|
|
172
|
+
spool webhooks list --json
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## API base URL
|
|
176
|
+
|
|
177
|
+
Default production API:
|
|
178
|
+
|
|
179
|
+
```text
|
|
180
|
+
https://socialspool.com/api/v1
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
OpenAPI machine contract:
|
|
184
|
+
|
|
185
|
+
```text
|
|
186
|
+
https://socialspool.com/api/docs/openapi
|
|
187
|
+
```
|
package/dist/spool.js
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/spool/socialspool.ts
|
|
4
|
+
import { writeFile } from "node:fs/promises";
|
|
5
|
+
import { realpathSync } from "node:fs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
// src/cli/spool/errors.ts
|
|
9
|
+
class SocialSpoolApiError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
body;
|
|
12
|
+
meta;
|
|
13
|
+
constructor(status, body, meta = {}) {
|
|
14
|
+
super(body.error?.message ?? `SocialSpool API request failed with HTTP ${status}`);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.body = body;
|
|
17
|
+
this.meta = meta;
|
|
18
|
+
this.name = "SocialSpoolApiError";
|
|
19
|
+
}
|
|
20
|
+
get code() {
|
|
21
|
+
return this.body.error?.code;
|
|
22
|
+
}
|
|
23
|
+
get requestId() {
|
|
24
|
+
return this.body.error?.requestId ?? this.meta.requestId;
|
|
25
|
+
}
|
|
26
|
+
get retryAfter() {
|
|
27
|
+
return this.meta.rateLimit?.retryAfter;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class SocialSpoolNetworkError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "SocialSpoolNetworkError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class UsageError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "UsageError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function exitCodeForError(error) {
|
|
45
|
+
if (error instanceof UsageError)
|
|
46
|
+
return 2;
|
|
47
|
+
if (error instanceof SocialSpoolNetworkError)
|
|
48
|
+
return 7;
|
|
49
|
+
if (error instanceof SocialSpoolApiError) {
|
|
50
|
+
if (error.status === 401 || error.status === 403)
|
|
51
|
+
return 4;
|
|
52
|
+
if (error.status === 400)
|
|
53
|
+
return 5;
|
|
54
|
+
if (error.status === 409)
|
|
55
|
+
return 6;
|
|
56
|
+
if (error.status === 429)
|
|
57
|
+
return 8;
|
|
58
|
+
}
|
|
59
|
+
if (error instanceof Error && error.name === "MissingApiKeyError")
|
|
60
|
+
return 3;
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/cli/spool/client.ts
|
|
65
|
+
class SocialSpoolClient {
|
|
66
|
+
config;
|
|
67
|
+
fetchImpl;
|
|
68
|
+
constructor(config, fetchImpl = fetch) {
|
|
69
|
+
this.config = config;
|
|
70
|
+
this.fetchImpl = fetchImpl;
|
|
71
|
+
}
|
|
72
|
+
async request(path, options = {}) {
|
|
73
|
+
const url = new URL(`${this.config.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
|
|
74
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
75
|
+
if (value !== undefined && value !== null && value !== "")
|
|
76
|
+
url.searchParams.set(key, String(value));
|
|
77
|
+
}
|
|
78
|
+
const headers = new Headers({
|
|
79
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
80
|
+
});
|
|
81
|
+
if (options.body !== undefined)
|
|
82
|
+
headers.set("Content-Type", "application/json");
|
|
83
|
+
if (options.idempotencyKey)
|
|
84
|
+
headers.set("Idempotency-Key", options.idempotencyKey);
|
|
85
|
+
let response;
|
|
86
|
+
try {
|
|
87
|
+
response = await this.fetchImpl(url, {
|
|
88
|
+
method: options.method ?? "GET",
|
|
89
|
+
headers,
|
|
90
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body)
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new SocialSpoolNetworkError(error instanceof Error ? error.message : "Network request failed");
|
|
94
|
+
}
|
|
95
|
+
const meta = readResponseMeta(response);
|
|
96
|
+
const parsed = await parseJson(response);
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new SocialSpoolApiError(response.status, parsed && typeof parsed === "object" ? parsed : {}, meta);
|
|
99
|
+
}
|
|
100
|
+
return { data: parsed, ...meta };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function parseJson(response) {
|
|
104
|
+
const text = await response.text();
|
|
105
|
+
if (!text.trim())
|
|
106
|
+
return null;
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(text);
|
|
109
|
+
} catch {
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
return {
|
|
112
|
+
error: {
|
|
113
|
+
code: "INVALID_JSON",
|
|
114
|
+
message: text,
|
|
115
|
+
statusCode: response.status
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
throw new SocialSpoolNetworkError("SocialSpool API returned invalid JSON");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function readResponseMeta(response) {
|
|
123
|
+
return {
|
|
124
|
+
requestId: response.headers.get("X-Request-Id") ?? undefined,
|
|
125
|
+
rateLimit: {
|
|
126
|
+
limit: response.headers.get("X-RateLimit-Limit") ?? undefined,
|
|
127
|
+
remaining: response.headers.get("X-RateLimit-Remaining") ?? undefined,
|
|
128
|
+
reset: response.headers.get("X-RateLimit-Reset") ?? undefined,
|
|
129
|
+
retryAfter: response.headers.get("Retry-After") ?? undefined
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/cli/spool/config.ts
|
|
135
|
+
var DEFAULT_API_BASE_URL = "https://socialspool.com/api/v1";
|
|
136
|
+
var DEFAULT_API_DOCS_URL = "https://socialspool.com/api/docs/openapi";
|
|
137
|
+
|
|
138
|
+
class MissingApiKeyError extends Error {
|
|
139
|
+
constructor() {
|
|
140
|
+
super("Missing SocialSpool API key. Pass --api-key or set SOCIALSPOOL_API_KEY.");
|
|
141
|
+
this.name = "MissingApiKeyError";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function resolveConfig(options, env = process.env) {
|
|
145
|
+
const apiKey = firstNonEmpty(options.apiKey, env.SOCIALSPOOL_API_KEY);
|
|
146
|
+
if (!apiKey)
|
|
147
|
+
throw new MissingApiKeyError;
|
|
148
|
+
return {
|
|
149
|
+
apiKey,
|
|
150
|
+
baseUrl: normalizeBaseUrl(firstNonEmpty(options.baseUrl, env.SOCIALSPOOL_API_BASE_URL) ?? DEFAULT_API_BASE_URL)
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function normalizeBaseUrl(value) {
|
|
154
|
+
const trimmed = value.trim();
|
|
155
|
+
if (!trimmed)
|
|
156
|
+
return DEFAULT_API_BASE_URL;
|
|
157
|
+
return trimmed.replace(/\/+$/, "");
|
|
158
|
+
}
|
|
159
|
+
function firstNonEmpty(...values) {
|
|
160
|
+
return values.find((value) => value?.trim())?.trim();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/cli/spool/commands.ts
|
|
164
|
+
import { readFile, stat } from "node:fs/promises";
|
|
165
|
+
import { basename } from "node:path";
|
|
166
|
+
|
|
167
|
+
// src/cli/spool/idempotency.ts
|
|
168
|
+
import { createHash } from "node:crypto";
|
|
169
|
+
function resolveIdempotencyKey(input) {
|
|
170
|
+
if (input.explicitKey?.trim())
|
|
171
|
+
return input.explicitKey.trim();
|
|
172
|
+
const stable = [input.command, ...input.parts.filter(Boolean)].join("|");
|
|
173
|
+
if (stable === input.command) {
|
|
174
|
+
throw new Error("Idempotency key required");
|
|
175
|
+
}
|
|
176
|
+
return `spool-${createHash("sha256").update(stable).digest("hex").slice(0, 32)}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/cli/spool/commands.ts
|
|
180
|
+
var POST_STYLES = ["text", "text_media", "media_only", "video_only"];
|
|
181
|
+
var TERMINAL_POST_STATUSES = new Set(["published", "failed", "canceled", "draft"]);
|
|
182
|
+
function parseArgs(args) {
|
|
183
|
+
const command = [];
|
|
184
|
+
const options = { accounts: [], mediaAssetIds: [], eventTypes: [] };
|
|
185
|
+
for (let i = 0;i < args.length; i += 1) {
|
|
186
|
+
const arg = args[i];
|
|
187
|
+
if (!arg.startsWith("-")) {
|
|
188
|
+
command.push(arg);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
switch (arg) {
|
|
192
|
+
case "--help":
|
|
193
|
+
case "-h":
|
|
194
|
+
options.help = true;
|
|
195
|
+
break;
|
|
196
|
+
case "--version":
|
|
197
|
+
options.version = true;
|
|
198
|
+
break;
|
|
199
|
+
case "--json":
|
|
200
|
+
options.json = true;
|
|
201
|
+
break;
|
|
202
|
+
case "--yes":
|
|
203
|
+
case "-y":
|
|
204
|
+
options.yes = true;
|
|
205
|
+
break;
|
|
206
|
+
case "--api-key":
|
|
207
|
+
options.apiKey = requireValue(args, ++i, arg);
|
|
208
|
+
break;
|
|
209
|
+
case "--base-url":
|
|
210
|
+
options.baseUrl = requireValue(args, ++i, arg);
|
|
211
|
+
break;
|
|
212
|
+
case "--idempotency-key":
|
|
213
|
+
options.idempotencyKey = requireValue(args, ++i, arg);
|
|
214
|
+
break;
|
|
215
|
+
case "--title":
|
|
216
|
+
options.title = requireValue(args, ++i, arg);
|
|
217
|
+
break;
|
|
218
|
+
case "--content":
|
|
219
|
+
options.content = requireValue(args, ++i, arg);
|
|
220
|
+
break;
|
|
221
|
+
case "--status":
|
|
222
|
+
options.status = requireValue(args, ++i, arg);
|
|
223
|
+
break;
|
|
224
|
+
case "--limit":
|
|
225
|
+
options.limit = parsePositiveInt(requireValue(args, ++i, arg), arg);
|
|
226
|
+
break;
|
|
227
|
+
case "--offset":
|
|
228
|
+
options.offset = parseNonNegativeInt(requireValue(args, ++i, arg), arg);
|
|
229
|
+
break;
|
|
230
|
+
case "--account":
|
|
231
|
+
options.accounts.push(requireValue(args, ++i, arg));
|
|
232
|
+
break;
|
|
233
|
+
case "--post-style":
|
|
234
|
+
options.postStyle = parsePostStyle(requireValue(args, ++i, arg), arg);
|
|
235
|
+
break;
|
|
236
|
+
case "--media-asset-id":
|
|
237
|
+
options.mediaAssetIds.push(requireValue(args, ++i, arg));
|
|
238
|
+
break;
|
|
239
|
+
case "--media-asset-ids":
|
|
240
|
+
options.mediaAssetIds.push(...parseCommaSeparatedValues(requireValue(args, ++i, arg), arg));
|
|
241
|
+
break;
|
|
242
|
+
case "--publish-at":
|
|
243
|
+
options.publishAt = requireValue(args, ++i, arg);
|
|
244
|
+
break;
|
|
245
|
+
case "--output":
|
|
246
|
+
case "-o":
|
|
247
|
+
options.output = requireValue(args, ++i, arg);
|
|
248
|
+
break;
|
|
249
|
+
case "--event-type":
|
|
250
|
+
options.eventType = requireValue(args, ++i, arg);
|
|
251
|
+
break;
|
|
252
|
+
case "--event-types":
|
|
253
|
+
options.eventTypes.push(...parseCommaSeparatedValues(requireValue(args, ++i, arg), arg));
|
|
254
|
+
break;
|
|
255
|
+
case "--q":
|
|
256
|
+
options.q = requireValue(args, ++i, arg);
|
|
257
|
+
break;
|
|
258
|
+
case "--timeout":
|
|
259
|
+
options.timeout = parsePositiveInt(requireValue(args, ++i, arg), arg);
|
|
260
|
+
break;
|
|
261
|
+
case "--interval":
|
|
262
|
+
options.interval = parsePositiveInt(requireValue(args, ++i, arg), arg);
|
|
263
|
+
break;
|
|
264
|
+
case "--url":
|
|
265
|
+
options.url = requireValue(args, ++i, arg);
|
|
266
|
+
break;
|
|
267
|
+
default:
|
|
268
|
+
throw new UsageError(`Unknown option: ${arg}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { command, options };
|
|
272
|
+
}
|
|
273
|
+
async function executeCommand(client, parsed) {
|
|
274
|
+
const [root, action, id] = parsed.command;
|
|
275
|
+
const { options } = parsed;
|
|
276
|
+
if (root === "me") {
|
|
277
|
+
const response = await client.request("/me");
|
|
278
|
+
return { command: "me", response };
|
|
279
|
+
}
|
|
280
|
+
if (root === "capabilities") {
|
|
281
|
+
const response = await client.request("/capabilities");
|
|
282
|
+
return { command: "capabilities", response };
|
|
283
|
+
}
|
|
284
|
+
if (root === "doctor") {
|
|
285
|
+
const response = await runDoctor(client);
|
|
286
|
+
return { command: "doctor", response };
|
|
287
|
+
}
|
|
288
|
+
if (root === "accounts" && action === "list") {
|
|
289
|
+
const response = await client.request("/social-accounts");
|
|
290
|
+
return { command: "accounts.list", response };
|
|
291
|
+
}
|
|
292
|
+
if (root === "media") {
|
|
293
|
+
if (action === "config") {
|
|
294
|
+
const response = await client.request("/media/config");
|
|
295
|
+
return { command: "media.config", response };
|
|
296
|
+
}
|
|
297
|
+
if (action === "upload") {
|
|
298
|
+
const filePath = requireCommandValue(id, "media upload requires <path>");
|
|
299
|
+
const idempotencyKey = resolveIdempotencyKey({
|
|
300
|
+
command: "media.upload",
|
|
301
|
+
explicitKey: options.idempotencyKey,
|
|
302
|
+
parts: [filePath]
|
|
303
|
+
});
|
|
304
|
+
const response = await uploadMediaFile(client, filePath, idempotencyKey);
|
|
305
|
+
return { command: "media.upload", idempotencyKey, response };
|
|
306
|
+
}
|
|
307
|
+
if (action === "delete") {
|
|
308
|
+
const mediaAssetId = requireCommandValue(id, "media delete requires <mediaAssetId>");
|
|
309
|
+
const idempotencyKey = resolveIdempotencyKey({
|
|
310
|
+
command: "media.delete",
|
|
311
|
+
explicitKey: options.idempotencyKey,
|
|
312
|
+
parts: [mediaAssetId]
|
|
313
|
+
});
|
|
314
|
+
const response = await client.request(`/media/${encodeURIComponent(mediaAssetId)}`, {
|
|
315
|
+
method: "DELETE",
|
|
316
|
+
idempotencyKey
|
|
317
|
+
});
|
|
318
|
+
return { command: "media.delete", idempotencyKey, response };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (root === "webhooks") {
|
|
322
|
+
if (action === "list") {
|
|
323
|
+
const response = await client.request("/webhooks");
|
|
324
|
+
return { command: "webhooks.list", response };
|
|
325
|
+
}
|
|
326
|
+
if (action === "create") {
|
|
327
|
+
if (!options.url)
|
|
328
|
+
throw new UsageError("webhooks create requires --url");
|
|
329
|
+
const eventTypes = options.eventTypes.length > 0 ? options.eventTypes : options.eventType ? [options.eventType] : [];
|
|
330
|
+
if (eventTypes.length === 0)
|
|
331
|
+
throw new UsageError("webhooks create requires --event-type or --event-types");
|
|
332
|
+
const idempotencyKey = resolveIdempotencyKey({
|
|
333
|
+
command: "webhooks.create",
|
|
334
|
+
explicitKey: options.idempotencyKey,
|
|
335
|
+
parts: [options.url, ...eventTypes]
|
|
336
|
+
});
|
|
337
|
+
const response = await client.request("/webhooks", {
|
|
338
|
+
method: "POST",
|
|
339
|
+
body: { url: options.url, event_types: eventTypes },
|
|
340
|
+
idempotencyKey
|
|
341
|
+
});
|
|
342
|
+
return { command: "webhooks.create", idempotencyKey, response };
|
|
343
|
+
}
|
|
344
|
+
if (action === "test") {
|
|
345
|
+
const webhookId = requireCommandValue(id, "webhooks test requires <webhookId>");
|
|
346
|
+
const idempotencyKey = resolveIdempotencyKey({
|
|
347
|
+
command: "webhooks.test",
|
|
348
|
+
explicitKey: options.idempotencyKey,
|
|
349
|
+
parts: [webhookId]
|
|
350
|
+
});
|
|
351
|
+
const response = await client.request(`/webhooks/${encodeURIComponent(webhookId)}/test`, {
|
|
352
|
+
method: "POST",
|
|
353
|
+
idempotencyKey
|
|
354
|
+
});
|
|
355
|
+
return { command: "webhooks.test", idempotencyKey, response };
|
|
356
|
+
}
|
|
357
|
+
if (action === "rotate-secret") {
|
|
358
|
+
const webhookId = requireCommandValue(id, "webhooks rotate-secret requires <webhookId>");
|
|
359
|
+
const idempotencyKey = resolveIdempotencyKey({
|
|
360
|
+
command: "webhooks.rotate-secret",
|
|
361
|
+
explicitKey: options.idempotencyKey,
|
|
362
|
+
parts: [webhookId]
|
|
363
|
+
});
|
|
364
|
+
const response = await client.request(`/webhooks/${encodeURIComponent(webhookId)}/rotate-secret`, {
|
|
365
|
+
method: "POST",
|
|
366
|
+
idempotencyKey
|
|
367
|
+
});
|
|
368
|
+
return { command: "webhooks.rotate-secret", idempotencyKey, response };
|
|
369
|
+
}
|
|
370
|
+
if (action === "delete") {
|
|
371
|
+
const webhookId = requireCommandValue(id, "webhooks delete requires <webhookId>");
|
|
372
|
+
const idempotencyKey = resolveIdempotencyKey({
|
|
373
|
+
command: "webhooks.delete",
|
|
374
|
+
explicitKey: options.idempotencyKey,
|
|
375
|
+
parts: [webhookId]
|
|
376
|
+
});
|
|
377
|
+
const response = await client.request(`/webhooks/${encodeURIComponent(webhookId)}`, {
|
|
378
|
+
method: "DELETE",
|
|
379
|
+
idempotencyKey
|
|
380
|
+
});
|
|
381
|
+
return { command: "webhooks.delete", idempotencyKey, response };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (root === "posts") {
|
|
385
|
+
if (action === "list") {
|
|
386
|
+
const response = await client.request("/posts", {
|
|
387
|
+
query: {
|
|
388
|
+
status: options.status,
|
|
389
|
+
limit: options.limit,
|
|
390
|
+
offset: options.offset
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
return { command: "posts.list", response };
|
|
394
|
+
}
|
|
395
|
+
if (action === "get") {
|
|
396
|
+
const postId = requireCommandValue(id, "posts get requires <postId>");
|
|
397
|
+
const response = await client.request(`/posts/${encodeURIComponent(postId)}`);
|
|
398
|
+
return { command: "posts.get", response };
|
|
399
|
+
}
|
|
400
|
+
if (action === "timeline") {
|
|
401
|
+
const postId = requireCommandValue(id, "posts timeline requires <postId>");
|
|
402
|
+
const response = await client.request(`/posts/${encodeURIComponent(postId)}/timeline`);
|
|
403
|
+
return { command: "posts.timeline", response };
|
|
404
|
+
}
|
|
405
|
+
if (action === "wait") {
|
|
406
|
+
const postId = requireCommandValue(id, "posts wait requires <postId>");
|
|
407
|
+
const response = await waitForPost(client, postId, options.timeout ?? 300, options.interval ?? 5);
|
|
408
|
+
return { command: "posts.wait", response };
|
|
409
|
+
}
|
|
410
|
+
if (action === "validate") {
|
|
411
|
+
if (!options.content && options.mediaAssetIds.length === 0) {
|
|
412
|
+
throw new UsageError("posts validate requires --content or --media-asset-ids");
|
|
413
|
+
}
|
|
414
|
+
if (options.accounts.length === 0)
|
|
415
|
+
throw new UsageError("posts validate requires --account");
|
|
416
|
+
const body = {
|
|
417
|
+
content: options.content ?? "",
|
|
418
|
+
social_account_ids: options.accounts
|
|
419
|
+
};
|
|
420
|
+
if (options.postStyle !== undefined)
|
|
421
|
+
body.post_style = options.postStyle;
|
|
422
|
+
if (options.mediaAssetIds.length > 0)
|
|
423
|
+
body.media_asset_ids = options.mediaAssetIds;
|
|
424
|
+
if (options.publishAt !== undefined) {
|
|
425
|
+
rejectReservedPublishAt(options.publishAt);
|
|
426
|
+
body.publish_at = options.publishAt;
|
|
427
|
+
}
|
|
428
|
+
const response = await client.request("/posts/validate", { method: "POST", body });
|
|
429
|
+
return { command: "posts.validate", response };
|
|
430
|
+
}
|
|
431
|
+
if (action === "create") {
|
|
432
|
+
if (!options.content && options.mediaAssetIds.length === 0) {
|
|
433
|
+
throw new UsageError("posts create requires --content or --media-asset-ids");
|
|
434
|
+
}
|
|
435
|
+
const body = { content: options.content ?? "" };
|
|
436
|
+
if (options.title !== undefined)
|
|
437
|
+
body.title = options.title;
|
|
438
|
+
if (options.postStyle !== undefined)
|
|
439
|
+
body.post_style = options.postStyle;
|
|
440
|
+
if (options.mediaAssetIds.length > 0)
|
|
441
|
+
body.media_asset_ids = options.mediaAssetIds;
|
|
442
|
+
if (options.accounts.length > 0)
|
|
443
|
+
body.social_account_ids = options.accounts;
|
|
444
|
+
if (options.publishAt !== undefined) {
|
|
445
|
+
rejectReservedPublishAt(options.publishAt);
|
|
446
|
+
if (options.accounts.length === 0) {
|
|
447
|
+
throw new UsageError("--account is required when --publish-at is provided");
|
|
448
|
+
}
|
|
449
|
+
body.publish_at = options.publishAt;
|
|
450
|
+
}
|
|
451
|
+
const idempotencyKey = resolveIdempotencyKey({
|
|
452
|
+
command: "posts.create",
|
|
453
|
+
explicitKey: options.idempotencyKey,
|
|
454
|
+
parts: [
|
|
455
|
+
String(body.content),
|
|
456
|
+
String(body.post_style ?? ""),
|
|
457
|
+
JSON.stringify(body.social_account_ids ?? []),
|
|
458
|
+
String(body.publish_at ?? ""),
|
|
459
|
+
JSON.stringify(body.media_asset_ids ?? [])
|
|
460
|
+
]
|
|
461
|
+
});
|
|
462
|
+
const response = await client.request("/posts", {
|
|
463
|
+
method: "POST",
|
|
464
|
+
body,
|
|
465
|
+
idempotencyKey
|
|
466
|
+
});
|
|
467
|
+
return { command: "posts.create", idempotencyKey, response };
|
|
468
|
+
}
|
|
469
|
+
if (action === "update") {
|
|
470
|
+
const postId = requireCommandValue(id, "posts update requires <postId>");
|
|
471
|
+
const body = {};
|
|
472
|
+
if (options.content !== undefined)
|
|
473
|
+
body.content = options.content;
|
|
474
|
+
if (options.title !== undefined)
|
|
475
|
+
body.title = options.title;
|
|
476
|
+
if (options.postStyle !== undefined)
|
|
477
|
+
body.post_style = options.postStyle;
|
|
478
|
+
if (Object.keys(body).length === 0)
|
|
479
|
+
throw new UsageError("posts update requires --content, --title, or --post-style");
|
|
480
|
+
const idempotencyKey = resolveIdempotencyKey({
|
|
481
|
+
command: "posts.update",
|
|
482
|
+
explicitKey: options.idempotencyKey,
|
|
483
|
+
parts: [postId, JSON.stringify(body)]
|
|
484
|
+
});
|
|
485
|
+
const response = await client.request(`/posts/${encodeURIComponent(postId)}`, {
|
|
486
|
+
method: "PATCH",
|
|
487
|
+
body,
|
|
488
|
+
idempotencyKey
|
|
489
|
+
});
|
|
490
|
+
return { command: "posts.update", idempotencyKey, response };
|
|
491
|
+
}
|
|
492
|
+
if (action === "delete") {
|
|
493
|
+
const postId = requireCommandValue(id, "posts delete requires <postId>");
|
|
494
|
+
if (!options.yes)
|
|
495
|
+
throw new UsageError("posts delete requires --yes");
|
|
496
|
+
const idempotencyKey = resolveIdempotencyKey({
|
|
497
|
+
command: "posts.delete",
|
|
498
|
+
explicitKey: options.idempotencyKey,
|
|
499
|
+
parts: [postId]
|
|
500
|
+
});
|
|
501
|
+
const response = await client.request(`/posts/${encodeURIComponent(postId)}`, {
|
|
502
|
+
method: "DELETE",
|
|
503
|
+
idempotencyKey
|
|
504
|
+
});
|
|
505
|
+
return { command: "posts.delete", idempotencyKey, response };
|
|
506
|
+
}
|
|
507
|
+
if (action === "schedule") {
|
|
508
|
+
const postId = requireCommandValue(id, "posts schedule requires <postId>");
|
|
509
|
+
if (options.accounts.length === 0)
|
|
510
|
+
throw new UsageError("posts schedule requires --account");
|
|
511
|
+
if (!options.publishAt)
|
|
512
|
+
throw new UsageError("posts schedule requires --publish-at");
|
|
513
|
+
rejectReservedPublishAt(options.publishAt);
|
|
514
|
+
const idempotencyKey = resolveIdempotencyKey({
|
|
515
|
+
command: "posts.schedule",
|
|
516
|
+
explicitKey: options.idempotencyKey,
|
|
517
|
+
parts: [postId, ...options.accounts, options.publishAt]
|
|
518
|
+
});
|
|
519
|
+
const response = await client.request(`/posts/${encodeURIComponent(postId)}/schedule`, {
|
|
520
|
+
method: "POST",
|
|
521
|
+
body: { social_account_ids: options.accounts, publish_at: options.publishAt },
|
|
522
|
+
idempotencyKey
|
|
523
|
+
});
|
|
524
|
+
return { command: "posts.schedule", idempotencyKey, response };
|
|
525
|
+
}
|
|
526
|
+
if (action === "publish-now") {
|
|
527
|
+
const postId = requireCommandValue(id, "posts publish-now requires <postId>");
|
|
528
|
+
if (options.accounts.length === 0)
|
|
529
|
+
throw new UsageError("posts publish-now requires --account");
|
|
530
|
+
const idempotencyKey = resolveIdempotencyKey({
|
|
531
|
+
command: "posts.publish-now",
|
|
532
|
+
explicitKey: options.idempotencyKey,
|
|
533
|
+
parts: [postId, ...options.accounts]
|
|
534
|
+
});
|
|
535
|
+
const response = await client.request(`/posts/${encodeURIComponent(postId)}/publish-now`, {
|
|
536
|
+
method: "POST",
|
|
537
|
+
body: { social_account_ids: options.accounts },
|
|
538
|
+
idempotencyKey
|
|
539
|
+
});
|
|
540
|
+
return { command: "posts.publish-now", idempotencyKey, response };
|
|
541
|
+
}
|
|
542
|
+
if (action === "cancel") {
|
|
543
|
+
const postId = requireCommandValue(id, "posts cancel requires <postId>");
|
|
544
|
+
const idempotencyKey = resolveIdempotencyKey({
|
|
545
|
+
command: "posts.cancel",
|
|
546
|
+
explicitKey: options.idempotencyKey,
|
|
547
|
+
parts: [postId]
|
|
548
|
+
});
|
|
549
|
+
const response = await client.request(`/posts/${encodeURIComponent(postId)}/cancel`, {
|
|
550
|
+
method: "POST",
|
|
551
|
+
idempotencyKey
|
|
552
|
+
});
|
|
553
|
+
return { command: "posts.cancel", idempotencyKey, response };
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
throw new UsageError(parsed.command.length ? `Unknown command: ${parsed.command.join(" ")}` : "Missing command");
|
|
557
|
+
}
|
|
558
|
+
async function runDoctor(client) {
|
|
559
|
+
const checks = [];
|
|
560
|
+
try {
|
|
561
|
+
const me = await client.request("/me");
|
|
562
|
+
checks.push({ name: "auth", ok: true, scopes: me.data.api_key?.scopes ?? [] });
|
|
563
|
+
} catch (error) {
|
|
564
|
+
checks.push({ name: "auth", ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
565
|
+
return { data: { ok: false, checks } };
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const capabilities = await client.request("/capabilities");
|
|
569
|
+
checks.push({ name: "capabilities", ok: true, features: capabilities.data.features });
|
|
570
|
+
} catch (error) {
|
|
571
|
+
checks.push({ name: "capabilities", ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
572
|
+
}
|
|
573
|
+
return { data: { ok: checks.every((check) => check.ok), checks } };
|
|
574
|
+
}
|
|
575
|
+
async function waitForPost(client, postId, timeoutSeconds, intervalSeconds) {
|
|
576
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
577
|
+
let latest = await client.request(`/posts/${encodeURIComponent(postId)}`);
|
|
578
|
+
while (Date.now() < deadline) {
|
|
579
|
+
const post = latest.data.post;
|
|
580
|
+
const statuses = [post?.status, ...post?.targets?.map((target) => target.status) ?? []].filter(Boolean);
|
|
581
|
+
if (statuses.every((status) => TERMINAL_POST_STATUSES.has(String(status)))) {
|
|
582
|
+
return latest;
|
|
583
|
+
}
|
|
584
|
+
await sleep(intervalSeconds * 1000);
|
|
585
|
+
latest = await client.request(`/posts/${encodeURIComponent(postId)}`);
|
|
586
|
+
}
|
|
587
|
+
return latest;
|
|
588
|
+
}
|
|
589
|
+
async function uploadMediaFile(client, filePath, idempotencyKey) {
|
|
590
|
+
const fileStats = await stat(filePath);
|
|
591
|
+
if (!fileStats.isFile())
|
|
592
|
+
throw new UsageError(`Not a file: ${filePath}`);
|
|
593
|
+
const fileBytes = await readFile(filePath);
|
|
594
|
+
const fileName = basename(filePath);
|
|
595
|
+
const mimeType = guessMimeType(fileName);
|
|
596
|
+
const created = await client.request("/media/uploads", {
|
|
597
|
+
method: "POST",
|
|
598
|
+
body: {
|
|
599
|
+
file_name: fileName,
|
|
600
|
+
mime_type: mimeType,
|
|
601
|
+
size_bytes: fileStats.size
|
|
602
|
+
},
|
|
603
|
+
idempotencyKey
|
|
604
|
+
});
|
|
605
|
+
const upload = created.data.upload;
|
|
606
|
+
const putResponse = await fetch(upload.url, {
|
|
607
|
+
method: upload.method,
|
|
608
|
+
headers: upload.headers,
|
|
609
|
+
body: new Blob([fileBytes], { type: mimeType })
|
|
610
|
+
});
|
|
611
|
+
if (!putResponse.ok) {
|
|
612
|
+
await client.request(`/media/${encodeURIComponent(created.data.media_asset.id)}/upload-failure`, {
|
|
613
|
+
method: "POST",
|
|
614
|
+
idempotencyKey: `${idempotencyKey}-failure`
|
|
615
|
+
});
|
|
616
|
+
throw new UsageError(`Media upload failed with HTTP ${putResponse.status}`);
|
|
617
|
+
}
|
|
618
|
+
return client.request(`/media/${encodeURIComponent(created.data.media_asset.id)}/confirm-upload`, {
|
|
619
|
+
method: "POST",
|
|
620
|
+
idempotencyKey: `${idempotencyKey}-confirm`
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
function guessMimeType(fileName) {
|
|
624
|
+
const lower = fileName.toLowerCase();
|
|
625
|
+
if (lower.endsWith(".png"))
|
|
626
|
+
return "image/png";
|
|
627
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
|
|
628
|
+
return "image/jpeg";
|
|
629
|
+
if (lower.endsWith(".gif"))
|
|
630
|
+
return "image/gif";
|
|
631
|
+
if (lower.endsWith(".webp"))
|
|
632
|
+
return "image/webp";
|
|
633
|
+
if (lower.endsWith(".mp4"))
|
|
634
|
+
return "video/mp4";
|
|
635
|
+
if (lower.endsWith(".mov"))
|
|
636
|
+
return "video/quicktime";
|
|
637
|
+
return "application/octet-stream";
|
|
638
|
+
}
|
|
639
|
+
function sleep(ms) {
|
|
640
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
641
|
+
}
|
|
642
|
+
async function fetchOpenApi(options, fetchImpl = fetch) {
|
|
643
|
+
const docsUrl = options.baseUrl ? `${new URL(normalizeBaseUrl(options.baseUrl)).origin}/api/docs/openapi` : DEFAULT_API_DOCS_URL;
|
|
644
|
+
const response = await fetchImpl(docsUrl);
|
|
645
|
+
return response.json();
|
|
646
|
+
}
|
|
647
|
+
function requireValue(args, index, flag) {
|
|
648
|
+
const value = args[index];
|
|
649
|
+
if (!value || value.startsWith("-"))
|
|
650
|
+
throw new UsageError(`${flag} requires a value`);
|
|
651
|
+
return value;
|
|
652
|
+
}
|
|
653
|
+
function requireCommandValue(value, message) {
|
|
654
|
+
if (!value)
|
|
655
|
+
throw new UsageError(message);
|
|
656
|
+
return value;
|
|
657
|
+
}
|
|
658
|
+
function parsePositiveInt(value, flag) {
|
|
659
|
+
const numberValue = Number(value);
|
|
660
|
+
if (!Number.isInteger(numberValue) || numberValue < 1)
|
|
661
|
+
throw new UsageError(`${flag} must be a positive integer`);
|
|
662
|
+
return numberValue;
|
|
663
|
+
}
|
|
664
|
+
function parseNonNegativeInt(value, flag) {
|
|
665
|
+
const numberValue = Number(value);
|
|
666
|
+
if (!Number.isInteger(numberValue) || numberValue < 0)
|
|
667
|
+
throw new UsageError(`${flag} must be a non-negative integer`);
|
|
668
|
+
return numberValue;
|
|
669
|
+
}
|
|
670
|
+
function parsePostStyle(value, flag) {
|
|
671
|
+
if (POST_STYLES.includes(value))
|
|
672
|
+
return value;
|
|
673
|
+
throw new UsageError(`${flag} must be one of: ${POST_STYLES.join(", ")}`);
|
|
674
|
+
}
|
|
675
|
+
function parseCommaSeparatedValues(value, flag) {
|
|
676
|
+
const values = value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
677
|
+
if (values.length === 0)
|
|
678
|
+
throw new UsageError(`${flag} requires at least one id`);
|
|
679
|
+
return values;
|
|
680
|
+
}
|
|
681
|
+
function rejectReservedPublishAt(value) {
|
|
682
|
+
if (value === "next-free-slot") {
|
|
683
|
+
throw new UsageError("--publish-at next-free-slot is reserved and currently rejected by the API");
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/cli/spool/format.ts
|
|
688
|
+
function formatSuccess(payload, json = false) {
|
|
689
|
+
if (json) {
|
|
690
|
+
return `${JSON.stringify({
|
|
691
|
+
ok: true,
|
|
692
|
+
command: payload.command,
|
|
693
|
+
...payload.idempotencyKey ? { idempotency_key: payload.idempotencyKey } : {},
|
|
694
|
+
...payload.requestId ? { request_id: payload.requestId } : {},
|
|
695
|
+
...payload.rateLimit ? { rate_limit: payload.rateLimit } : {},
|
|
696
|
+
result: payload.result
|
|
697
|
+
}, null, 2)}
|
|
698
|
+
`;
|
|
699
|
+
}
|
|
700
|
+
return `${formatHuman(payload.result)}
|
|
701
|
+
`;
|
|
702
|
+
}
|
|
703
|
+
function formatError(error, status, json = false) {
|
|
704
|
+
if (json) {
|
|
705
|
+
if (error instanceof SocialSpoolApiError) {
|
|
706
|
+
return `${JSON.stringify({
|
|
707
|
+
ok: false,
|
|
708
|
+
status: error.status,
|
|
709
|
+
error: error.body.error,
|
|
710
|
+
...error.requestId ? { request_id: error.requestId } : {},
|
|
711
|
+
...error.retryAfter ? { retry_after: error.retryAfter } : {}
|
|
712
|
+
}, null, 2)}
|
|
713
|
+
`;
|
|
714
|
+
}
|
|
715
|
+
return `${JSON.stringify({
|
|
716
|
+
ok: false,
|
|
717
|
+
status,
|
|
718
|
+
error: { message: error instanceof Error ? error.message : String(error) }
|
|
719
|
+
}, null, 2)}
|
|
720
|
+
`;
|
|
721
|
+
}
|
|
722
|
+
if (error instanceof SocialSpoolApiError) {
|
|
723
|
+
const requestId = error.requestId ? ` (${error.requestId})` : "";
|
|
724
|
+
const retryAfter = error.retryAfter ? ` retry-after=${error.retryAfter}s` : "";
|
|
725
|
+
return `${error.body.error?.code ?? "API_ERROR"}: ${error.message}${requestId}${retryAfter}
|
|
726
|
+
`;
|
|
727
|
+
}
|
|
728
|
+
return `${error instanceof Error ? error.message : String(error)}
|
|
729
|
+
`;
|
|
730
|
+
}
|
|
731
|
+
function formatHuman(data) {
|
|
732
|
+
if (data && typeof data === "object") {
|
|
733
|
+
if ("workspace" in data && "api_key" in data) {
|
|
734
|
+
const value = data;
|
|
735
|
+
return [
|
|
736
|
+
`Workspace: ${value.workspace.name} (${value.workspace.plan})`,
|
|
737
|
+
`Workspace ID: ${value.workspace.id}`,
|
|
738
|
+
`API key: ${value.api_key.name} (${value.api_key.id})`,
|
|
739
|
+
`Scopes: ${value.api_key.scopes.join(", ")}`
|
|
740
|
+
].join(`
|
|
741
|
+
`);
|
|
742
|
+
}
|
|
743
|
+
if ("features" in data && "limits" in data && "workspace" in data) {
|
|
744
|
+
const value = data;
|
|
745
|
+
const enabled = Object.entries(value.features).filter(([, on]) => on).map(([name]) => name).join(", ");
|
|
746
|
+
return [`Workspace: ${value.workspace.name} (${value.workspace.plan})`, `Features: ${enabled}`].join(`
|
|
747
|
+
`);
|
|
748
|
+
}
|
|
749
|
+
if ("results" in data && Array.isArray(data.results)) {
|
|
750
|
+
const value = data;
|
|
751
|
+
if (value.results.length === 0)
|
|
752
|
+
return "No results.";
|
|
753
|
+
return value.results.map((item) => {
|
|
754
|
+
const id = item.id ? ` ${item.id}` : "";
|
|
755
|
+
const label = item.username ?? item.title ?? item.content ?? item.platform ?? "item";
|
|
756
|
+
const status = item.status ? ` [${item.status}]` : "";
|
|
757
|
+
return `- ${String(label)}${id}${status}`;
|
|
758
|
+
}).join(`
|
|
759
|
+
`);
|
|
760
|
+
}
|
|
761
|
+
if ("post" in data) {
|
|
762
|
+
const post = data.post;
|
|
763
|
+
return [
|
|
764
|
+
`Post: ${String(post.id)}`,
|
|
765
|
+
`Status: ${String(post.status)}`,
|
|
766
|
+
post.title ? `Title: ${String(post.title)}` : undefined,
|
|
767
|
+
`Content: ${String(post.content)}`
|
|
768
|
+
].filter(Boolean).join(`
|
|
769
|
+
`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return JSON.stringify(data, null, 2);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// src/cli/spool/socialspool.ts
|
|
776
|
+
var VERSION = "1.0.0";
|
|
777
|
+
async function runSpoolCli(args, env = process.env, fetchImpl = fetch) {
|
|
778
|
+
const parsed = parseArgs(args);
|
|
779
|
+
if (parsed.options.help)
|
|
780
|
+
return { exitCode: 0, stdout: helpText() };
|
|
781
|
+
if (parsed.options.version)
|
|
782
|
+
return { exitCode: 0, stdout: `spool ${VERSION}
|
|
783
|
+
` };
|
|
784
|
+
try {
|
|
785
|
+
if (parsed.command[0] === "openapi") {
|
|
786
|
+
const data = await fetchOpenApi({ baseUrl: parsed.options.baseUrl }, fetchImpl);
|
|
787
|
+
if (parsed.options.output) {
|
|
788
|
+
await writeFile(parsed.options.output, `${JSON.stringify(data, null, 2)}
|
|
789
|
+
`, "utf8");
|
|
790
|
+
return { exitCode: 0, stdout: `Wrote OpenAPI docs to ${parsed.options.output}
|
|
791
|
+
` };
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
exitCode: 0,
|
|
795
|
+
stdout: formatSuccess({ command: "openapi", result: data }, parsed.options.json)
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
const config = resolveConfig(parsed.options, env);
|
|
799
|
+
const client = new SocialSpoolClient(config, fetchImpl);
|
|
800
|
+
const executed = await executeCommand(client, parsed);
|
|
801
|
+
return {
|
|
802
|
+
exitCode: 0,
|
|
803
|
+
stdout: formatSuccess({
|
|
804
|
+
command: executed.command,
|
|
805
|
+
idempotencyKey: executed.idempotencyKey,
|
|
806
|
+
requestId: executed.response.requestId,
|
|
807
|
+
rateLimit: executed.response.rateLimit,
|
|
808
|
+
result: executed.response.data
|
|
809
|
+
}, parsed.options.json)
|
|
810
|
+
};
|
|
811
|
+
} catch (error) {
|
|
812
|
+
return {
|
|
813
|
+
exitCode: exitCodeForError(error),
|
|
814
|
+
stderr: formatError(error, undefined, parsed.options.json)
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
function helpText() {
|
|
819
|
+
return `spool ${VERSION}
|
|
820
|
+
|
|
821
|
+
Agent-friendly CLI for the SocialSpool Public API.
|
|
822
|
+
|
|
823
|
+
Usage:
|
|
824
|
+
spool [global options] <command> [command options]
|
|
825
|
+
|
|
826
|
+
Global options:
|
|
827
|
+
--api-key <key> SocialSpool API key. Defaults to SOCIALSPOOL_API_KEY.
|
|
828
|
+
--base-url <url> API base URL. Defaults to https://socialspool.com/api/v1.
|
|
829
|
+
--json Emit machine-readable JSON.
|
|
830
|
+
--idempotency-key <key> Override auto-generated idempotency key for writes.
|
|
831
|
+
--help, -h Show this help.
|
|
832
|
+
--version Show CLI version.
|
|
833
|
+
|
|
834
|
+
Core commands:
|
|
835
|
+
me
|
|
836
|
+
capabilities
|
|
837
|
+
doctor
|
|
838
|
+
openapi [--output openapi.json]
|
|
839
|
+
accounts list
|
|
840
|
+
posts list [--status status] [--limit 20] [--offset 0]
|
|
841
|
+
posts get <postId>
|
|
842
|
+
posts validate --content <text> --account <id> [--publish-at ISO|now] [--post-style style] [--media-asset-ids ids] --json
|
|
843
|
+
posts create [--content <text>] [--title <title>] [--post-style text|text_media|media_only|video_only] [--media-asset-ids <id,id>] [--account <id>...] [--publish-at now|ISO] [--idempotency-key <key>]
|
|
844
|
+
posts update <postId> [--content <text>] [--title <title>] [--post-style style]
|
|
845
|
+
posts delete <postId> --yes
|
|
846
|
+
posts schedule <postId> --account <id>... --publish-at <ISO> [--idempotency-key <key>]
|
|
847
|
+
posts publish-now <postId> --account <id>... [--idempotency-key <key>]
|
|
848
|
+
posts cancel <postId>
|
|
849
|
+
posts wait <postId> [--timeout 300] [--interval 5]
|
|
850
|
+
posts timeline <postId>
|
|
851
|
+
media config
|
|
852
|
+
media upload <path>
|
|
853
|
+
media delete <mediaAssetId>
|
|
854
|
+
webhooks list
|
|
855
|
+
webhooks create --url <url> --event-type <type>|--event-types <a,b>
|
|
856
|
+
webhooks test <webhookId>
|
|
857
|
+
webhooks rotate-secret <webhookId>
|
|
858
|
+
webhooks delete <webhookId>
|
|
859
|
+
|
|
860
|
+
Examples:
|
|
861
|
+
SOCIALSPOOL_API_KEY=ssp_live_xxx spool me --json
|
|
862
|
+
SOCIALSPOOL_API_KEY=ssp_live_xxx spool capabilities --json
|
|
863
|
+
SOCIALSPOOL_API_KEY=ssp_live_xxx spool accounts list --json
|
|
864
|
+
SOCIALSPOOL_API_KEY=ssp_live_xxx spool posts validate --content "Hello" --account acct_1 --json
|
|
865
|
+
SOCIALSPOOL_API_KEY=ssp_live_xxx spool posts create --content "Hello from an agent" --json
|
|
866
|
+
SOCIALSPOOL_API_KEY=ssp_live_xxx spool posts wait post_123 --json
|
|
867
|
+
`;
|
|
868
|
+
}
|
|
869
|
+
function isMainModule(metaUrl) {
|
|
870
|
+
const entrypoint = process.argv[1];
|
|
871
|
+
if (!entrypoint)
|
|
872
|
+
return false;
|
|
873
|
+
return realpathSync(entrypoint) === realpathSync(fileURLToPath(metaUrl));
|
|
874
|
+
}
|
|
875
|
+
if (isMainModule(import.meta.url)) {
|
|
876
|
+
const result = await runSpoolCli(process.argv.slice(2));
|
|
877
|
+
if (result.stdout)
|
|
878
|
+
process.stdout.write(result.stdout);
|
|
879
|
+
if (result.stderr)
|
|
880
|
+
process.stderr.write(result.stderr);
|
|
881
|
+
process.exit(result.exitCode);
|
|
882
|
+
}
|
|
883
|
+
export {
|
|
884
|
+
runSpoolCli,
|
|
885
|
+
helpText
|
|
886
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thesethrose/socialspool-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Agent-friendly CLI for the SocialSpool Public API.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"spool": "dist/spool.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/spool.js",
|
|
11
|
+
"./package.json": "./package.json",
|
|
12
|
+
"./SKILL.md": "./SKILL.md"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"SKILL.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20.19.0"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"license": "UNLICENSED"
|
|
25
|
+
}
|