@chirag127/omni-publish 0.1.0 → 0.1.1

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 CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Chirag Singhal
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.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chirag Singhal
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/README.md CHANGED
@@ -1,66 +1,66 @@
1
- # @chirag127/omni-publish
2
-
3
- [![npm](https://img.shields.io/npm/v/@chirag127/omni-publish.svg)](https://www.npmjs.com/package/@chirag127/omni-publish)
4
- [![CI](https://github.com/chirag127/omni-publish-npm-pkg/actions/workflows/ci.yml/badge.svg)](https://github.com/chirag127/omni-publish-npm-pkg/actions/workflows/ci.yml)
5
- [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
-
7
- Auto-publish release notes / blog posts to multiple platforms on tag push or release create. Platform fan-out is filtered by which env vars are set — if only `DEVTO_API_KEY` exists, only dev.to is targeted.
8
-
9
- ## Install
10
-
11
- ```sh
12
- npm i -D @chirag127/omni-publish
13
- # or
14
- pnpm add -D @chirag127/omni-publish
15
- ```
16
-
17
- ## Supported platforms / env vars
18
-
19
- Set the env var for each platform you want to publish to. Unset = skipped.
20
-
21
- | Platform | Env var |
22
- |--------------|----------------------|
23
- | dev.to | `DEVTO_API_KEY` |
24
- | Hashnode | `HASHNODE_API_KEY` |
25
- | Medium | `MEDIUM_API_KEY` |
26
- | X / Twitter | `X_API_KEY` |
27
- | LinkedIn | `LINKEDIN_API_KEY` |
28
- | Bluesky | `BLUESKY_API_KEY` |
29
- | Mastodon | `MASTODON_API_KEY` |
30
- | Reddit | `REDDIT_API_KEY` |
31
-
32
- ## GitHub Actions
33
-
34
- ```yaml
35
- name: Cross-post release
36
- on:
37
- release: { types: [published] }
38
- push: { tags: ['v*.*.*'] }
39
- jobs:
40
- publish:
41
- runs-on: ubuntu-latest
42
- steps:
43
- - uses: actions/checkout@v4
44
- - uses: actions/setup-node@v4
45
- with: { node-version: 22 }
46
- - run: npx -y @chirag127/omni-publish
47
- env:
48
- DEVTO_API_KEY: ${{ secrets.DEVTO_API_KEY }}
49
- HASHNODE_API_KEY: ${{ secrets.HASHNODE_API_KEY }}
50
- MEDIUM_API_KEY: ${{ secrets.MEDIUM_API_KEY }}
51
- X_API_KEY: ${{ secrets.X_API_KEY }}
52
- LINKEDIN_API_KEY: ${{ secrets.LINKEDIN_API_KEY }}
53
- BLUESKY_API_KEY: ${{ secrets.BLUESKY_API_KEY }}
54
- MASTODON_API_KEY: ${{ secrets.MASTODON_API_KEY }}
55
- REDDIT_API_KEY: ${{ secrets.REDDIT_API_KEY }}
56
- ```
57
-
58
- ## Status
59
-
60
- `v0.1.x` — slug reservation + stub. Per-platform adapters land in `v0.1.1`.
61
-
62
- Part of the [`chirag127/oriz`](https://github.com/chirag127/oriz) family.
63
-
64
- ## Cross-refs
65
-
66
- - [security policy](./SECURITY.md) · [code of conduct](./CODE_OF_CONDUCT.md) · [contributing](./CONTRIBUTING.md)
1
+ # @chirag127/omni-publish
2
+
3
+ [![npm](https://img.shields.io/npm/v/@chirag127/omni-publish.svg)](https://www.npmjs.com/package/@chirag127/omni-publish)
4
+ [![CI](https://github.com/chirag127/omni-publish-npm-pkg/actions/workflows/ci.yml/badge.svg)](https://github.com/chirag127/omni-publish-npm-pkg/actions/workflows/ci.yml)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
+
7
+ Auto-publish release notes / blog posts to multiple platforms on tag push or release create. Platform fan-out is filtered by which env vars are set — if only `DEVTO_API_KEY` exists, only dev.to is targeted.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm i -D @chirag127/omni-publish
13
+ # or
14
+ pnpm add -D @chirag127/omni-publish
15
+ ```
16
+
17
+ ## Supported platforms / env vars
18
+
19
+ Set the env var for each platform you want to publish to. Unset = skipped.
20
+
21
+ | Platform | Env var |
22
+ |--------------|----------------------|
23
+ | dev.to | `DEVTO_API_KEY` |
24
+ | Hashnode | `HASHNODE_API_KEY` |
25
+ | Medium | `MEDIUM_API_KEY` |
26
+ | X / Twitter | `X_API_KEY` |
27
+ | LinkedIn | `LINKEDIN_API_KEY` |
28
+ | Bluesky | `BLUESKY_API_KEY` |
29
+ | Mastodon | `MASTODON_API_KEY` |
30
+ | Reddit | `REDDIT_API_KEY` |
31
+
32
+ ## GitHub Actions
33
+
34
+ ```yaml
35
+ name: Cross-post release
36
+ on:
37
+ release: { types: [published] }
38
+ push: { tags: ['v*.*.*'] }
39
+ jobs:
40
+ publish:
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+ - uses: actions/setup-node@v4
45
+ with: { node-version: 22 }
46
+ - run: npx -y @chirag127/omni-publish
47
+ env:
48
+ DEVTO_API_KEY: ${{ secrets.DEVTO_API_KEY }}
49
+ HASHNODE_API_KEY: ${{ secrets.HASHNODE_API_KEY }}
50
+ MEDIUM_API_KEY: ${{ secrets.MEDIUM_API_KEY }}
51
+ X_API_KEY: ${{ secrets.X_API_KEY }}
52
+ LINKEDIN_API_KEY: ${{ secrets.LINKEDIN_API_KEY }}
53
+ BLUESKY_API_KEY: ${{ secrets.BLUESKY_API_KEY }}
54
+ MASTODON_API_KEY: ${{ secrets.MASTODON_API_KEY }}
55
+ REDDIT_API_KEY: ${{ secrets.REDDIT_API_KEY }}
56
+ ```
57
+
58
+ ## Status
59
+
60
+ `v0.1.x` — slug reservation + stub. Per-platform adapters land in `v0.1.1`.
61
+
62
+ Part of the [`chirag127/oriz`](https://github.com/chirag127/oriz) family.
63
+
64
+ ## Cross-refs
65
+
66
+ - [security policy](./SECURITY.md) · [code of conduct](./CODE_OF_CONDUCT.md) · [contributing](./CONTRIBUTING.md)
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env node
2
+ // omni-publish bin entry — plain ESM JS so npm doesn't strip the shebang
3
+ // and we don't need a build step. Mirrors src/cli.ts logic via dynamic import
4
+ // of the TS sources... except npm consumers can't run .ts directly.
5
+ //
6
+ // Strategy: this file inlines just enough to do the fan-out in pure JS using
7
+ // Node 22+ native fetch. It imports @atproto/api lazily for Bluesky.
8
+
9
+ import { createRequire } from 'node:module'
10
+ const _require = createRequire(import.meta.url)
11
+ void _require
12
+
13
+ function parseArgs(argv) {
14
+ const out = {}
15
+ for (let i = 2; i < argv.length; i++) {
16
+ const a = argv[i]
17
+ if (a.startsWith('--')) {
18
+ const key = a.slice(2)
19
+ const next = argv[i + 1]
20
+ if (next === undefined || next.startsWith('--')) {
21
+ out[key] = 'true'
22
+ } else {
23
+ out[key] = next
24
+ i++
25
+ }
26
+ }
27
+ }
28
+ return out
29
+ }
30
+
31
+ // ---------- adapters ----------
32
+
33
+ async function devto(input) {
34
+ const apiKey = process.env.DEVTO_API_KEY
35
+ if (!apiKey) return { platform: 'dev.to', status: 'skipped' }
36
+ try {
37
+ const body = {
38
+ article: {
39
+ title: input.title,
40
+ body_markdown: input.body + '\n\n---\n\n*Originally posted at ' + input.canonical_url + '*',
41
+ published: true,
42
+ canonical_url: input.canonical_url,
43
+ tags: input.tags?.slice(0, 4),
44
+ },
45
+ }
46
+ const res = await fetch('https://dev.to/api/articles', {
47
+ method: 'POST',
48
+ headers: { 'api-key': apiKey, 'content-type': 'application/json' },
49
+ body: JSON.stringify(body),
50
+ })
51
+ if (!res.ok) {
52
+ const t = await res.text()
53
+ return { platform: 'dev.to', status: 'error', error: `HTTP ${res.status}: ${t.slice(0, 200)}` }
54
+ }
55
+ const json = await res.json()
56
+ return { platform: 'dev.to', status: 'ok', url: json.url }
57
+ } catch (e) {
58
+ return { platform: 'dev.to', status: 'error', error: String(e) }
59
+ }
60
+ }
61
+
62
+ async function hashnode(input) {
63
+ const apiKey = process.env.HASHNODE_API_KEY
64
+ const publicationId = process.env.HASHNODE_PUBLICATION_ID
65
+ if (!apiKey || !publicationId) return { platform: 'hashnode', status: 'skipped' }
66
+ try {
67
+ const MUT = `mutation PublishPost($input: PublishPostInput!) { publishPost(input: $input) { post { id slug url } } }`
68
+ const variables = {
69
+ input: {
70
+ title: input.title,
71
+ contentMarkdown: input.body,
72
+ publicationId,
73
+ originalArticleURL: input.canonical_url,
74
+ tags: (input.tags || []).map((t) => ({ slug: t.toLowerCase().replace(/\s+/g, '-'), name: t })),
75
+ },
76
+ }
77
+ const res = await fetch('https://gql.hashnode.com/', {
78
+ method: 'POST',
79
+ headers: { Authorization: apiKey, 'content-type': 'application/json' },
80
+ body: JSON.stringify({ query: MUT, variables }),
81
+ })
82
+ if (!res.ok) {
83
+ const t = await res.text()
84
+ return { platform: 'hashnode', status: 'error', error: `HTTP ${res.status}: ${t.slice(0, 200)}` }
85
+ }
86
+ const json = await res.json()
87
+ if (json.errors) return { platform: 'hashnode', status: 'error', error: JSON.stringify(json.errors).slice(0, 200) }
88
+ return { platform: 'hashnode', status: 'ok', url: json.data?.publishPost?.post?.url }
89
+ } catch (e) {
90
+ return { platform: 'hashnode', status: 'error', error: String(e) }
91
+ }
92
+ }
93
+
94
+ async function bluesky(input) {
95
+ const handle = process.env.BLUESKY_HANDLE
96
+ const appPassword = process.env.BLUESKY_APP_PASSWORD
97
+ if (!handle || !appPassword) return { platform: 'bluesky', status: 'skipped' }
98
+ try {
99
+ const mod = await import('@atproto/api')
100
+ const AtpAgent = mod.AtpAgent || mod.default?.AtpAgent
101
+ const agent = new AtpAgent({ service: 'https://bsky.social' })
102
+ await agent.login({ identifier: handle, password: appPassword })
103
+ const text = `${input.title}\n\n${input.canonical_url}`.slice(0, 300)
104
+ const res = await agent.post({ text, createdAt: new Date().toISOString() })
105
+ return { platform: 'bluesky', status: 'ok', url: res.uri }
106
+ } catch (e) {
107
+ return { platform: 'bluesky', status: 'error', error: String(e) }
108
+ }
109
+ }
110
+
111
+ async function mastodon(input) {
112
+ const instance = process.env.MASTODON_INSTANCE
113
+ const token = process.env.MASTODON_ACCESS_TOKEN
114
+ if (!instance || !token) return { platform: 'mastodon', status: 'skipped' }
115
+ try {
116
+ let status = input.title + '\n\n' + input.canonical_url
117
+ if (status.length > 500) status = status.slice(0, 497) + '...'
118
+ const url = `https://${instance.replace(/^https?:\/\//, '').replace(/\/$/, '')}/api/v1/statuses`
119
+ const res = await fetch(url, {
120
+ method: 'POST',
121
+ headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' },
122
+ body: JSON.stringify({ status, visibility: 'public' }),
123
+ })
124
+ if (!res.ok) {
125
+ const t = await res.text()
126
+ return { platform: 'mastodon', status: 'error', error: `HTTP ${res.status}: ${t.slice(0, 200)}` }
127
+ }
128
+ const json = await res.json()
129
+ return { platform: 'mastodon', status: 'ok', url: json.url }
130
+ } catch (e) {
131
+ return { platform: 'mastodon', status: 'error', error: String(e) }
132
+ }
133
+ }
134
+
135
+ async function threads(input) {
136
+ const token = process.env.THREADS_ACCESS_TOKEN
137
+ if (!token) return { platform: 'threads', status: 'skipped' }
138
+ try {
139
+ let text = input.title + '\n\n' + input.canonical_url
140
+ if (text.length > 500) text = text.slice(0, 497) + '...'
141
+ const createUrl = new URL('https://graph.threads.net/v1.0/me/threads')
142
+ createUrl.searchParams.set('media_type', 'TEXT')
143
+ createUrl.searchParams.set('text', text)
144
+ createUrl.searchParams.set('access_token', token)
145
+ const createRes = await fetch(createUrl.toString(), { method: 'POST' })
146
+ if (!createRes.ok) {
147
+ const t = await createRes.text()
148
+ return { platform: 'threads', status: 'error', error: `create HTTP ${createRes.status}: ${t.slice(0, 200)}` }
149
+ }
150
+ const createJson = await createRes.json()
151
+ if (!createJson.id) return { platform: 'threads', status: 'error', error: 'no creation_id returned' }
152
+ const pubUrl = new URL('https://graph.threads.net/v1.0/me/threads_publish')
153
+ pubUrl.searchParams.set('creation_id', createJson.id)
154
+ pubUrl.searchParams.set('access_token', token)
155
+ const pubRes = await fetch(pubUrl.toString(), { method: 'POST' })
156
+ if (!pubRes.ok) {
157
+ const t = await pubRes.text()
158
+ return { platform: 'threads', status: 'error', error: `publish HTTP ${pubRes.status}: ${t.slice(0, 200)}` }
159
+ }
160
+ const pubJson = await pubRes.json()
161
+ return { platform: 'threads', status: 'ok', url: pubJson.id ? `https://www.threads.net/@me/post/${pubJson.id}` : undefined }
162
+ } catch (e) {
163
+ return { platform: 'threads', status: 'error', error: String(e) }
164
+ }
165
+ }
166
+
167
+ // ---------- AI drafts ----------
168
+
169
+ const PROMPTS = {
170
+ x: (title, body) => `Rewrite this release announcement as a Twitter/X post (280 char max), keeping the canonical URL. Be punchy. Source:\n\n${title}\n\n${body}`,
171
+ reddit: (title, body) => `Rewrite this release announcement as a Reddit self-post. Keep the full body but make it conversational and add a TL;DR at the top. Source:\n\n${title}\n\n${body}`,
172
+ linkedin: (title, body) => `Rewrite this release announcement as a LinkedIn post in a professional, slightly humble tone. ~3 short paragraphs. Source:\n\n${title}\n\n${body}`,
173
+ medium: (title, body) => `Rewrite this release announcement as a 1-paragraph Medium blurb (~80 words) suitable as a post intro. Source:\n\n${title}\n\n${body}`,
174
+ }
175
+
176
+ async function callChat(url, model, key, prompt) {
177
+ try {
178
+ const res = await fetch(url, {
179
+ method: 'POST',
180
+ headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
181
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], temperature: 0.7, max_tokens: 512 }),
182
+ })
183
+ if (!res.ok) {
184
+ const text = await res.text()
185
+ return { ok: false, status: res.status, error: text.slice(0, 200) }
186
+ }
187
+ const json = await res.json()
188
+ const content = json.choices?.[0]?.message?.content?.trim()
189
+ if (!content) return { ok: false, error: 'empty response' }
190
+ return { ok: true, text: content }
191
+ } catch (e) {
192
+ return { ok: false, error: String(e) }
193
+ }
194
+ }
195
+
196
+ async function generateDraft(prompt) {
197
+ const nimKey = process.env.NVIDIA_NIM_API_KEY
198
+ const orKey = process.env.OPENROUTER_API_KEY
199
+ if (nimKey) {
200
+ const r = await callChat('https://integrate.api.nvidia.com/v1/chat/completions', 'meta/llama-3.3-70b-instruct', nimKey, prompt)
201
+ if (r.ok && r.text) return r.text
202
+ }
203
+ if (orKey) {
204
+ const r = await callChat('https://openrouter.ai/api/v1/chat/completions', 'meta-llama/llama-3.3-70b-instruct:free', orKey, prompt)
205
+ if (r.ok && r.text) return r.text
206
+ }
207
+ return null
208
+ }
209
+
210
+ async function sendTelegram(token, chatId, text) {
211
+ try {
212
+ const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
213
+ method: 'POST',
214
+ headers: { 'content-type': 'application/json' },
215
+ body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'Markdown', disable_web_page_preview: false }),
216
+ })
217
+ if (!res.ok) {
218
+ const t = await res.text()
219
+ return { ok: false, error: `HTTP ${res.status}: ${t.slice(0, 200)}` }
220
+ }
221
+ return { ok: true }
222
+ } catch (e) {
223
+ return { ok: false, error: String(e) }
224
+ }
225
+ }
226
+
227
+ function makeDraftAdapter(platform, label) {
228
+ return async (input) => {
229
+ const name = `telegram-draft-${platform}`
230
+ const token = process.env.TELEGRAM_DRAFTS_BOT_TOKEN
231
+ const chatId = process.env.TELEGRAM_DRAFTS_CHAT_ID
232
+ if (!token || !chatId) return { platform: name, status: 'skipped' }
233
+ try {
234
+ const draft = await generateDraft(PROMPTS[platform](input.title, input.body))
235
+ if (!draft) return { platform: name, status: 'error', error: 'AI draft generation failed (no NIM/OpenRouter key or both errored)' }
236
+ const text = `📋 DRAFT (${label}) for: ${input.title}\n\n${draft}\n\nOriginal: ${input.canonical_url}`
237
+ const r = await sendTelegram(token, chatId, text)
238
+ if (!r.ok) return { platform: name, status: 'error', error: r.error }
239
+ return { platform: name, status: 'ok' }
240
+ } catch (e) {
241
+ return { platform: name, status: 'error', error: String(e) }
242
+ }
243
+ }
244
+ }
245
+
246
+ const DRAFT_ADAPTERS = [
247
+ makeDraftAdapter('x', 'X'),
248
+ makeDraftAdapter('reddit', 'Reddit'),
249
+ makeDraftAdapter('linkedin', 'LinkedIn'),
250
+ makeDraftAdapter('medium', 'Medium'),
251
+ ]
252
+
253
+ const AUTO_ADAPTERS = [devto, hashnode, bluesky, mastodon, threads]
254
+
255
+ async function publishAll(input) {
256
+ const all = [...AUTO_ADAPTERS, ...DRAFT_ADAPTERS]
257
+ return Promise.all(
258
+ all.map((fn) =>
259
+ fn(input).catch((e) => ({ platform: fn.name || 'unknown', status: 'error', error: String(e) }))
260
+ )
261
+ )
262
+ }
263
+
264
+ async function main() {
265
+ const args = parseArgs(process.argv)
266
+ if (!args.title || !args.canonical) {
267
+ console.error('Usage: omni-publish --title "..." --body "..." --canonical "..." --type <app|package|book|extension> [--tags a,b,c]')
268
+ process.exit(1)
269
+ }
270
+ const input = {
271
+ title: args.title,
272
+ body: args.body || '',
273
+ canonical_url: args.canonical,
274
+ type: args.type || 'package',
275
+ tags: args.tags ? args.tags.split(',').map((t) => t.trim()).filter(Boolean) : undefined,
276
+ }
277
+ const results = await publishAll(input)
278
+ for (const r of results) {
279
+ const sym = r.status === 'ok' ? '✓' : r.status === 'skipped' ? '-' : '✗'
280
+ let line = `${sym} ${r.platform}`
281
+ if (r.url) line += ` → ${r.url}`
282
+ if (r.error) line += ` (${r.error})`
283
+ console.log(line)
284
+ }
285
+ process.exit(0)
286
+ }
287
+
288
+ main().catch((e) => {
289
+ console.error('omni-publish fatal:', e)
290
+ process.exit(1)
291
+ })
package/package.json CHANGED
@@ -1,25 +1,29 @@
1
1
  {
2
2
  "name": "@chirag127/omni-publish",
3
- "version": "0.1.0",
4
- "description": "Auto-publish release notes / blog posts to multiple platforms (dev.to, hashnode, medium, X/Twitter, LinkedIn, Bluesky, Mastodon, Reddit) on tag push or release create. Platform fan-out filtered by which env vars are set.",
3
+ "version": "0.1.1",
4
+ "description": "Auto-publish release notes / blog posts to multiple platforms (dev.to, Hashnode, Bluesky, Mastodon, Threads) + Telegram drafts queue for manual platforms (X, Reddit, LinkedIn, Medium). Platform fan-out is env-gated.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Chirag Singhal <chirag@oriz.in>",
8
8
  "homepage": "https://github.com/chirag127/omni-publish-npm-pkg#readme",
9
9
  "repository": { "type": "git", "url": "git+https://github.com/chirag127/omni-publish-npm-pkg.git" },
10
10
  "bugs": { "url": "https://github.com/chirag127/omni-publish-npm-pkg/issues" },
11
- "keywords": ["chirag127", "oriz", "release-notes", "devto", "hashnode", "medium", "twitter", "linkedin", "bluesky", "mastodon", "reddit", "cross-post"],
12
- "files": ["src", "LICENSE", "README.md"],
11
+ "keywords": ["chirag127", "oriz", "release-notes", "devto", "hashnode", "medium", "twitter", "linkedin", "bluesky", "mastodon", "reddit", "threads", "cross-post", "telegram-drafts"],
12
+ "files": ["src", "bin", "LICENSE", "README.md"],
13
13
  "main": "./src/index.ts",
14
14
  "types": "./src/index.ts",
15
- "bin": { "omni-publish": "./src/index.ts" },
15
+ "bin": { "omni-publish": "./bin/omni-publish.mjs" },
16
16
  "exports": { ".": "./src/index.ts" },
17
17
  "publishConfig": { "access": "public" },
18
+ "engines": { "node": ">=22" },
18
19
  "scripts": {
19
20
  "test": "vitest run",
20
21
  "test:watch": "vitest",
21
22
  "test:coverage": "vitest run --coverage"
22
23
  },
24
+ "dependencies": {
25
+ "@atproto/api": "^0.13.0"
26
+ },
23
27
  "devDependencies": {
24
28
  "vitest": "^2.0.0",
25
29
  "@vitest/coverage-v8": "^2.0.0",
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest'
2
+ import { __pkg, publish } from '../index'
3
+ import * as devto from '../adapters/devto'
4
+ import * as hashnode from '../adapters/hashnode'
5
+ import * as bluesky from '../adapters/bluesky'
6
+ import * as mastodon from '../adapters/mastodon'
7
+ import * as threads from '../adapters/threads'
8
+ import { DRAFT_ADAPTERS } from '../adapters/telegram-drafts'
9
+
10
+ // Snapshot env vars relevant to omni-publish, clear them for these tests,
11
+ // and restore afterwards.
12
+ const ENV_VARS = [
13
+ 'DEVTO_API_KEY',
14
+ 'HASHNODE_API_KEY',
15
+ 'HASHNODE_PUBLICATION_ID',
16
+ 'BLUESKY_HANDLE',
17
+ 'BLUESKY_APP_PASSWORD',
18
+ 'MASTODON_INSTANCE',
19
+ 'MASTODON_ACCESS_TOKEN',
20
+ 'THREADS_ACCESS_TOKEN',
21
+ 'NVIDIA_NIM_API_KEY',
22
+ 'OPENROUTER_API_KEY',
23
+ 'TELEGRAM_DRAFTS_BOT_TOKEN',
24
+ 'TELEGRAM_DRAFTS_CHAT_ID',
25
+ ]
26
+
27
+ let saved: Record<string, string | undefined> = {}
28
+
29
+ describe('package smoke', () => {
30
+ beforeEach(() => {
31
+ saved = {}
32
+ for (const k of ENV_VARS) {
33
+ saved[k] = process.env[k]
34
+ delete process.env[k]
35
+ }
36
+ })
37
+
38
+ afterEach(() => {
39
+ for (const k of ENV_VARS) {
40
+ if (saved[k] === undefined) delete process.env[k]
41
+ else process.env[k] = saved[k]
42
+ }
43
+ })
44
+
45
+ it('exports __pkg constant', () => {
46
+ expect(__pkg).toMatch(/^@chirag127\//)
47
+ })
48
+
49
+ it('each auto-adapter exports a name string', () => {
50
+ expect(typeof devto.name).toBe('string')
51
+ expect(typeof hashnode.name).toBe('string')
52
+ expect(typeof bluesky.name).toBe('string')
53
+ expect(typeof mastodon.name).toBe('string')
54
+ expect(typeof threads.name).toBe('string')
55
+ })
56
+
57
+ it('each draft adapter exports a name string', () => {
58
+ for (const a of DRAFT_ADAPTERS) {
59
+ expect(typeof a.name).toBe('string')
60
+ expect(a.name.startsWith('telegram-draft-')).toBe(true)
61
+ }
62
+ })
63
+
64
+ it('publish() returns all-skipped results when no env vars are set', async () => {
65
+ const results = await publish({
66
+ title: 'test',
67
+ body: 'b',
68
+ canonical_url: 'https://example.com',
69
+ type: 'package',
70
+ tags: ['t'],
71
+ })
72
+ expect(Array.isArray(results)).toBe(true)
73
+ expect(results.length).toBe(9) // 5 auto + 4 draft
74
+ for (const r of results) {
75
+ expect(r.status).toBe('skipped')
76
+ }
77
+ })
78
+ })
@@ -0,0 +1,70 @@
1
+ // AI draft generator — NVIDIA NIM primary, OpenRouter fallback
2
+ // Used by telegram-drafts adapter to rewrite release announcements per-platform.
3
+
4
+ const NIM_URL = 'https://integrate.api.nvidia.com/v1/chat/completions'
5
+ const NIM_MODEL = 'meta/llama-3.3-70b-instruct'
6
+ const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
7
+ const OPENROUTER_MODEL = 'meta-llama/llama-3.3-70b-instruct:free'
8
+
9
+ async function callChat(url: string, model: string, key: string, prompt: string): Promise<{ ok: boolean; text?: string; status?: number; error?: string }> {
10
+ try {
11
+ const res = await fetch(url, {
12
+ method: 'POST',
13
+ headers: {
14
+ Authorization: `Bearer ${key}`,
15
+ 'content-type': 'application/json',
16
+ },
17
+ body: JSON.stringify({
18
+ model,
19
+ messages: [{ role: 'user', content: prompt }],
20
+ temperature: 0.7,
21
+ max_tokens: 512,
22
+ }),
23
+ })
24
+ if (!res.ok) {
25
+ const text = await res.text()
26
+ return { ok: false, status: res.status, error: text.slice(0, 200) }
27
+ }
28
+ const json = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> }
29
+ const content = json.choices?.[0]?.message?.content?.trim()
30
+ if (!content) return { ok: false, error: 'empty response' }
31
+ return { ok: true, text: content }
32
+ } catch (e) {
33
+ return { ok: false, error: String(e) }
34
+ }
35
+ }
36
+
37
+ export async function generateDraft(prompt: string): Promise<string | null> {
38
+ const nimKey = process.env.NVIDIA_NIM_API_KEY
39
+ const orKey = process.env.OPENROUTER_API_KEY
40
+
41
+ // Try NIM first
42
+ if (nimKey) {
43
+ const r = await callChat(NIM_URL, NIM_MODEL, nimKey, prompt)
44
+ if (r.ok && r.text) return r.text
45
+ // Fall through on 429/5xx
46
+ if (r.status && r.status !== 429 && r.status < 500) {
47
+ // 4xx other than 429 — don't fall back
48
+ if (!orKey) return null
49
+ }
50
+ }
51
+
52
+ // Fallback to OpenRouter
53
+ if (orKey) {
54
+ const r = await callChat(OPENROUTER_URL, OPENROUTER_MODEL, orKey, prompt)
55
+ if (r.ok && r.text) return r.text
56
+ }
57
+
58
+ return null
59
+ }
60
+
61
+ export const PROMPTS = {
62
+ x: (title: string, body: string) =>
63
+ `Rewrite this release announcement as a Twitter/X post (280 char max), keeping the canonical URL. Be punchy. Source:\n\n${title}\n\n${body}`,
64
+ reddit: (title: string, body: string) =>
65
+ `Rewrite this release announcement as a Reddit self-post. Keep the full body but make it conversational and add a TL;DR at the top. Source:\n\n${title}\n\n${body}`,
66
+ linkedin: (title: string, body: string) =>
67
+ `Rewrite this release announcement as a LinkedIn post in a professional, slightly humble tone. ~3 short paragraphs. Source:\n\n${title}\n\n${body}`,
68
+ medium: (title: string, body: string) =>
69
+ `Rewrite this release announcement as a 1-paragraph Medium blurb (~80 words) suitable as a post intro. Source:\n\n${title}\n\n${body}`,
70
+ }
@@ -0,0 +1,28 @@
1
+ // Bluesky adapter — AT Protocol
2
+ import type { PublishInput, PublishResult } from '../types.js'
3
+
4
+ export const name = 'bluesky'
5
+
6
+ export async function publish(input: PublishInput): Promise<PublishResult> {
7
+ const handle = process.env.BLUESKY_HANDLE
8
+ const appPassword = process.env.BLUESKY_APP_PASSWORD
9
+ if (!handle || !appPassword) {
10
+ return { platform: name, status: 'skipped' }
11
+ }
12
+
13
+ try {
14
+ // Dynamic import so the dep is only loaded when needed
15
+ const { AtpAgent } = await import('@atproto/api')
16
+ const agent = new AtpAgent({ service: 'https://bsky.social' })
17
+ await agent.login({ identifier: handle, password: appPassword })
18
+
19
+ const text = `${input.title}\n\n${input.canonical_url}`.slice(0, 300)
20
+ const res = await agent.post({
21
+ text,
22
+ createdAt: new Date().toISOString(),
23
+ })
24
+ return { platform: name, status: 'ok', url: res.uri }
25
+ } catch (e) {
26
+ return { platform: name, status: 'error', error: String(e) }
27
+ }
28
+ }
@@ -0,0 +1,39 @@
1
+ // dev.to adapter
2
+ import type { PublishInput, PublishResult } from '../types.js'
3
+
4
+ export const name = 'dev.to'
5
+
6
+ export async function publish(input: PublishInput): Promise<PublishResult> {
7
+ const apiKey = process.env.DEVTO_API_KEY
8
+ if (!apiKey) {
9
+ return { platform: name, status: 'skipped' }
10
+ }
11
+
12
+ try {
13
+ const body = {
14
+ article: {
15
+ title: input.title,
16
+ body_markdown: input.body + '\n\n---\n\n*Originally posted at ' + input.canonical_url + '*',
17
+ published: true,
18
+ canonical_url: input.canonical_url,
19
+ tags: input.tags?.slice(0, 4),
20
+ },
21
+ }
22
+ const res = await fetch('https://dev.to/api/articles', {
23
+ method: 'POST',
24
+ headers: {
25
+ 'api-key': apiKey,
26
+ 'content-type': 'application/json',
27
+ },
28
+ body: JSON.stringify(body),
29
+ })
30
+ if (!res.ok) {
31
+ const text = await res.text()
32
+ return { platform: name, status: 'error', error: `HTTP ${res.status}: ${text.slice(0, 200)}` }
33
+ }
34
+ const json = (await res.json()) as { url?: string }
35
+ return { platform: name, status: 'ok', url: json.url }
36
+ } catch (e) {
37
+ return { platform: name, status: 'error', error: String(e) }
38
+ }
39
+ }
@@ -0,0 +1,52 @@
1
+ // Hashnode adapter — GraphQL
2
+ import type { PublishInput, PublishResult } from '../types.js'
3
+
4
+ export const name = 'hashnode'
5
+
6
+ const MUTATION = `mutation PublishPost($input: PublishPostInput!) {
7
+ publishPost(input: $input) {
8
+ post { id slug url }
9
+ }
10
+ }`
11
+
12
+ export async function publish(input: PublishInput): Promise<PublishResult> {
13
+ const apiKey = process.env.HASHNODE_API_KEY
14
+ const publicationId = process.env.HASHNODE_PUBLICATION_ID
15
+ if (!apiKey || !publicationId) {
16
+ return { platform: name, status: 'skipped' }
17
+ }
18
+
19
+ try {
20
+ const variables = {
21
+ input: {
22
+ title: input.title,
23
+ contentMarkdown: input.body,
24
+ publicationId,
25
+ originalArticleURL: input.canonical_url,
26
+ tags: (input.tags || []).map((t) => ({
27
+ slug: t.toLowerCase().replace(/\s+/g, '-'),
28
+ name: t,
29
+ })),
30
+ },
31
+ }
32
+ const res = await fetch('https://gql.hashnode.com/', {
33
+ method: 'POST',
34
+ headers: {
35
+ Authorization: apiKey,
36
+ 'content-type': 'application/json',
37
+ },
38
+ body: JSON.stringify({ query: MUTATION, variables }),
39
+ })
40
+ if (!res.ok) {
41
+ const text = await res.text()
42
+ return { platform: name, status: 'error', error: `HTTP ${res.status}: ${text.slice(0, 200)}` }
43
+ }
44
+ const json = (await res.json()) as { data?: { publishPost?: { post?: { url?: string } } }; errors?: unknown }
45
+ if (json.errors) {
46
+ return { platform: name, status: 'error', error: JSON.stringify(json.errors).slice(0, 200) }
47
+ }
48
+ return { platform: name, status: 'ok', url: json.data?.publishPost?.post?.url }
49
+ } catch (e) {
50
+ return { platform: name, status: 'error', error: String(e) }
51
+ }
52
+ }
@@ -0,0 +1,35 @@
1
+ // Mastodon adapter
2
+ import type { PublishInput, PublishResult } from '../types.js'
3
+
4
+ export const name = 'mastodon'
5
+
6
+ export async function publish(input: PublishInput): Promise<PublishResult> {
7
+ const instance = process.env.MASTODON_INSTANCE
8
+ const token = process.env.MASTODON_ACCESS_TOKEN
9
+ if (!instance || !token) {
10
+ return { platform: name, status: 'skipped' }
11
+ }
12
+
13
+ try {
14
+ let status = input.title + '\n\n' + input.canonical_url
15
+ if (status.length > 500) status = status.slice(0, 497) + '...'
16
+
17
+ const url = `https://${instance.replace(/^https?:\/\//, '').replace(/\/$/, '')}/api/v1/statuses`
18
+ const res = await fetch(url, {
19
+ method: 'POST',
20
+ headers: {
21
+ Authorization: `Bearer ${token}`,
22
+ 'content-type': 'application/json',
23
+ },
24
+ body: JSON.stringify({ status, visibility: 'public' }),
25
+ })
26
+ if (!res.ok) {
27
+ const text = await res.text()
28
+ return { platform: name, status: 'error', error: `HTTP ${res.status}: ${text.slice(0, 200)}` }
29
+ }
30
+ const json = (await res.json()) as { url?: string }
31
+ return { platform: name, status: 'ok', url: json.url }
32
+ } catch (e) {
33
+ return { platform: name, status: 'error', error: String(e) }
34
+ }
35
+ }
@@ -0,0 +1,66 @@
1
+ // Telegram drafts queue — for manual platforms (X, Reddit, LinkedIn, Medium)
2
+ // Generates an AI draft per platform and posts it to the Telegram drafts channel
3
+ // so the user can review + post manually.
4
+
5
+ import type { PublishInput, PublishResult } from '../types.js'
6
+ import { generateDraft, PROMPTS } from './ai-draft.js'
7
+
8
+ type PlatformKey = 'x' | 'reddit' | 'linkedin' | 'medium'
9
+
10
+ async function sendTelegram(token: string, chatId: string, text: string): Promise<{ ok: boolean; error?: string }> {
11
+ try {
12
+ const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
13
+ method: 'POST',
14
+ headers: { 'content-type': 'application/json' },
15
+ body: JSON.stringify({
16
+ chat_id: chatId,
17
+ text,
18
+ parse_mode: 'Markdown',
19
+ disable_web_page_preview: false,
20
+ }),
21
+ })
22
+ if (!res.ok) {
23
+ const t = await res.text()
24
+ return { ok: false, error: `HTTP ${res.status}: ${t.slice(0, 200)}` }
25
+ }
26
+ return { ok: true }
27
+ } catch (e) {
28
+ return { ok: false, error: String(e) }
29
+ }
30
+ }
31
+
32
+ function makeAdapter(platform: PlatformKey, label: string) {
33
+ return {
34
+ name: `telegram-draft-${platform}`,
35
+ async publish(input: PublishInput): Promise<PublishResult> {
36
+ const token = process.env.TELEGRAM_DRAFTS_BOT_TOKEN
37
+ const chatId = process.env.TELEGRAM_DRAFTS_CHAT_ID
38
+ if (!token || !chatId) {
39
+ return { platform: this.name, status: 'skipped' }
40
+ }
41
+
42
+ try {
43
+ const prompt = PROMPTS[platform](input.title, input.body)
44
+ const draft = await generateDraft(prompt)
45
+ if (!draft) {
46
+ return { platform: this.name, status: 'error', error: 'AI draft generation failed (no NIM/OpenRouter key or both errored)' }
47
+ }
48
+ const text = `📋 DRAFT (${label}) for: ${input.title}\n\n${draft}\n\nOriginal: ${input.canonical_url}`
49
+ const r = await sendTelegram(token, chatId, text)
50
+ if (!r.ok) {
51
+ return { platform: this.name, status: 'error', error: r.error }
52
+ }
53
+ return { platform: this.name, status: 'ok' }
54
+ } catch (e) {
55
+ return { platform: this.name, status: 'error', error: String(e) }
56
+ }
57
+ },
58
+ }
59
+ }
60
+
61
+ export const xDraft = makeAdapter('x', 'X')
62
+ export const redditDraft = makeAdapter('reddit', 'Reddit')
63
+ export const linkedinDraft = makeAdapter('linkedin', 'LinkedIn')
64
+ export const mediumDraft = makeAdapter('medium', 'Medium')
65
+
66
+ export const DRAFT_ADAPTERS = [xDraft, redditDraft, linkedinDraft, mediumDraft]
@@ -0,0 +1,45 @@
1
+ // Threads (Meta) adapter — 2-step create + publish
2
+ import type { PublishInput, PublishResult } from '../types.js'
3
+
4
+ export const name = 'threads'
5
+
6
+ export async function publish(input: PublishInput): Promise<PublishResult> {
7
+ const token = process.env.THREADS_ACCESS_TOKEN
8
+ if (!token) {
9
+ return { platform: name, status: 'skipped' }
10
+ }
11
+
12
+ try {
13
+ let text = input.title + '\n\n' + input.canonical_url
14
+ if (text.length > 500) text = text.slice(0, 497) + '...'
15
+
16
+ // Step 1: create container
17
+ const createUrl = new URL('https://graph.threads.net/v1.0/me/threads')
18
+ createUrl.searchParams.set('media_type', 'TEXT')
19
+ createUrl.searchParams.set('text', text)
20
+ createUrl.searchParams.set('access_token', token)
21
+ const createRes = await fetch(createUrl.toString(), { method: 'POST' })
22
+ if (!createRes.ok) {
23
+ const t = await createRes.text()
24
+ return { platform: name, status: 'error', error: `create HTTP ${createRes.status}: ${t.slice(0, 200)}` }
25
+ }
26
+ const createJson = (await createRes.json()) as { id?: string }
27
+ if (!createJson.id) {
28
+ return { platform: name, status: 'error', error: 'no creation_id returned' }
29
+ }
30
+
31
+ // Step 2: publish
32
+ const pubUrl = new URL('https://graph.threads.net/v1.0/me/threads_publish')
33
+ pubUrl.searchParams.set('creation_id', createJson.id)
34
+ pubUrl.searchParams.set('access_token', token)
35
+ const pubRes = await fetch(pubUrl.toString(), { method: 'POST' })
36
+ if (!pubRes.ok) {
37
+ const t = await pubRes.text()
38
+ return { platform: name, status: 'error', error: `publish HTTP ${pubRes.status}: ${t.slice(0, 200)}` }
39
+ }
40
+ const pubJson = (await pubRes.json()) as { id?: string }
41
+ return { platform: name, status: 'ok', url: pubJson.id ? `https://www.threads.net/@me/post/${pubJson.id}` : undefined }
42
+ } catch (e) {
43
+ return { platform: name, status: 'error', error: String(e) }
44
+ }
45
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // omni-publish CLI
3
+ // Usage: omni-publish --title "..." --body "..." --canonical "..." --type package --tags astro,oriz
4
+ import { publish } from './publish.js'
5
+ import type { PublishInput } from './types.js'
6
+
7
+ function parseArgs(argv: string[]): Record<string, string> {
8
+ const out: Record<string, string> = {}
9
+ for (let i = 2; i < argv.length; i++) {
10
+ const a = argv[i]
11
+ if (a.startsWith('--')) {
12
+ const key = a.slice(2)
13
+ const next = argv[i + 1]
14
+ if (next === undefined || next.startsWith('--')) {
15
+ out[key] = 'true'
16
+ } else {
17
+ out[key] = next
18
+ i++
19
+ }
20
+ }
21
+ }
22
+ return out
23
+ }
24
+
25
+ async function main() {
26
+ const args = parseArgs(process.argv)
27
+ if (!args.title || !args.canonical) {
28
+ console.error('Usage: omni-publish --title "..." --body "..." --canonical "..." --type <app|package|book|extension> [--tags a,b,c]')
29
+ process.exit(1)
30
+ }
31
+ const type = (args.type as PublishInput['type']) || 'package'
32
+ const input: PublishInput = {
33
+ title: args.title,
34
+ body: args.body || '',
35
+ canonical_url: args.canonical,
36
+ type,
37
+ tags: args.tags ? args.tags.split(',').map((t) => t.trim()).filter(Boolean) : undefined,
38
+ }
39
+
40
+ const results = await publish(input)
41
+ let anyError = false
42
+ for (const r of results) {
43
+ const sym = r.status === 'ok' ? '✓' : r.status === 'skipped' ? '-' : '✗'
44
+ let line = `${sym} ${r.platform}`
45
+ if (r.url) line += ` → ${r.url}`
46
+ if (r.error) line += ` (${r.error})`
47
+ console.log(line)
48
+ if (r.status === 'error') anyError = true
49
+ }
50
+ // Don't fail the workflow on per-platform errors — env-gated by design.
51
+ process.exit(0)
52
+ void anyError
53
+ }
54
+
55
+ main().catch((e) => {
56
+ console.error('omni-publish fatal:', e)
57
+ process.exit(1)
58
+ })
package/src/index.ts CHANGED
@@ -1,15 +1,21 @@
1
- #!/usr/bin/env node
2
1
  // @chirag127/omni-publish
3
- // v0.1.0 stub. See README for status.
2
+ // v0.1.1 — 5 working adapters (dev.to, Hashnode, Bluesky, Mastodon, Threads)
3
+ // + Telegram drafts queue for 4 manual platforms (X, Reddit, LinkedIn, Medium).
4
+ //
5
+ // Library API:
6
+ // import { publish, type PublishInput, type PublishResult } from '@chirag127/omni-publish'
7
+ //
8
+ // CLI (Node 22+):
9
+ // npx @chirag127/omni-publish --title "..." --body "..." --canonical "..." --type package --tags a,b
4
10
  export const __pkg = '@chirag127/omni-publish' as const
5
11
 
12
+ export type { PublishInput, PublishResult, Adapter } from './types.js'
13
+ export { publish } from './publish.js'
14
+
15
+ // Legacy config type kept for v0.1.0 back-compat (unused by new publish()).
6
16
  export type OmniPublishConfig = {
7
17
  platforms: string[]
8
18
  title: string
9
19
  body: string
10
20
  canonicalUrl?: string
11
21
  }
12
-
13
- export async function publish(_cfg: OmniPublishConfig): Promise<void> {
14
- /* TODO: implement per-platform adapters in v0.1.1 */
15
- }
package/src/publish.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Main publish orchestrator — fans out to auto-publish adapters and Telegram drafts.
2
+ import type { PublishInput, PublishResult } from './types.js'
3
+ import * as devto from './adapters/devto.js'
4
+ import * as hashnode from './adapters/hashnode.js'
5
+ import * as bluesky from './adapters/bluesky.js'
6
+ import * as mastodon from './adapters/mastodon.js'
7
+ import * as threads from './adapters/threads.js'
8
+ import { DRAFT_ADAPTERS } from './adapters/telegram-drafts.js'
9
+
10
+ const AUTO_ADAPTERS = [devto, hashnode, bluesky, mastodon, threads]
11
+
12
+ export async function publish(input: PublishInput): Promise<PublishResult[]> {
13
+ const autoResults = await Promise.all(
14
+ AUTO_ADAPTERS.map((a) =>
15
+ a.publish(input).catch((e): PublishResult => ({ platform: a.name, status: 'error', error: String(e) }))
16
+ )
17
+ )
18
+ const draftResults = await Promise.all(
19
+ DRAFT_ADAPTERS.map((a) =>
20
+ a.publish(input).catch((e): PublishResult => ({ platform: a.name, status: 'error', error: String(e) }))
21
+ )
22
+ )
23
+ return [...autoResults, ...draftResults]
24
+ }
package/src/types.ts ADDED
@@ -0,0 +1,21 @@
1
+ // Shared types for @chirag127/omni-publish
2
+
3
+ export type PublishInput = {
4
+ title: string
5
+ body: string
6
+ canonical_url: string
7
+ type: 'app' | 'package' | 'book' | 'extension'
8
+ tags?: string[]
9
+ }
10
+
11
+ export type PublishResult = {
12
+ platform: string
13
+ status: 'ok' | 'skipped' | 'error'
14
+ url?: string
15
+ error?: string
16
+ }
17
+
18
+ export type Adapter = {
19
+ name: string
20
+ publish: (input: PublishInput) => Promise<PublishResult>
21
+ }
@@ -1,12 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { __pkg, publish } from '../index'
3
-
4
- describe('package smoke', () => {
5
- it('exports __pkg constant', () => {
6
- expect(__pkg).toMatch(/^@chirag127\//)
7
- })
8
-
9
- it('publish() resolves without throwing on empty config', async () => {
10
- await expect(publish({ platforms: [], title: 't', body: 'b' })).resolves.toBeUndefined()
11
- })
12
- })