Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fd5b4fa
docs(marketing/channels): spec for channel-adapters sub-spec
blove May 18, 2026
30a18c2
docs(marketing/channels): plan for channel-adapters sub-spec
blove May 18, 2026
b7ded06
chore: hoist msw as direct devDep for channel-adapter tests
blove May 18, 2026
7ea3a47
feat(marketing/channels): add vitest config + Nx test target
blove May 18, 2026
99de4e2
feat(marketing/channels): extract types module
blove May 18, 2026
275016d
feat(marketing/channels): add validateDraft + ValidationError
blove May 18, 2026
609c685
feat(marketing/channels): add http() wrapper with retry + 401 hook
blove May 18, 2026
ce14143
feat(marketing/channels): add writeDryRunResult
blove May 18, 2026
dd5ca74
feat(marketing/channels): X auth state machine with refresh
blove May 18, 2026
0f98159
feat(marketing/channels): X OAuth 2.0 bootstrapper CLI
blove May 18, 2026
6451aec
feat(marketing/channels): X media upload via /2/media/upload
blove May 18, 2026
c7a7056
feat(marketing/channels): X post + thread + media composition
blove May 18, 2026
9683a18
feat(marketing/channels): add XAdapter class
blove May 18, 2026
3a07c85
feat(marketing/channels): registry + getAdapter()
blove May 18, 2026
42efddb
feat(marketing/channels): public API surface
blove May 18, 2026
7a3802d
docs(marketing): update X env vars to OAuth 2.0
blove May 18, 2026
a2049f7
docs(marketing/channels): add package README
blove May 18, 2026
27da113
docs(marketing/channels): manual smoke recipe + script
blove May 18, 2026
baec4e2
feat(marketing/cowork): dry-runs dir + gitignore for generated JSON
blove May 18, 2026
c49bf11
fix(marketing/channels): load .env via tsx --env-file in CLI scripts
blove May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,209 changes: 2,209 additions & 0 deletions docs/superpowers/plans/marketing/2026-05-17-channel-adapters.md

Large diffs are not rendered by default.

384 changes: 384 additions & 0 deletions docs/superpowers/specs/marketing/2026-05-17-channel-adapters-design.md

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions marketing/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
# Sub-specs may add more keys. This file is documentation, not consumed at
# runtime — each adapter reads its own keys via process.env.

# X / Twitter
X_API_KEY=
X_API_SECRET=
# X / Twitter (OAuth 2.0 with PKCE — see marketing/channels/README.md)
X_CLIENT_ID=
X_CLIENT_SECRET=
X_ACCESS_TOKEN=
X_ACCESS_SECRET=
X_REFRESH_TOKEN=
X_USER_HANDLE=

# LinkedIn
LINKEDIN_ACCESS_TOKEN=
Expand Down
41 changes: 41 additions & 0 deletions marketing/channels/MANUAL-SMOKE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# X adapter — manual smoke

Run after the bootstrapper has populated `.env`.

> **Note:** Use the npm scripts below (or pass `--env-file=.env` to tsx directly). Plain `npx tsx ...` does NOT auto-load `.env`, so the adapter will throw "missing env vars".

## 1. Dry-run (no API calls)

```bash
DRY_RUN=1 pnpm marketing:channels:x:smoke
```

Expect: a JSON `PostResult` printed with `postId` prefixed `dry-` and a file under `marketing/cowork/outbox/dry-runs/`.

## 2. Live single tweet

```bash
pnpm marketing:channels:x:smoke
```

Expect: a real `https://x.com/<handle>/status/<id>` URL. Open it; confirm the post is on the timeline. **Then delete the post from the X UI.**

## 3. Live tweet with media

```bash
SMOKE_MEDIA=1 pnpm marketing:channels:x:smoke
```

Expect: the post has a 1×1 transparent pixel attached with the alt text. Delete after verifying.

## 4. Live thread

```bash
SMOKE_THREAD=1 pnpm marketing:channels:x:smoke
```

Expect: two tweets posted; the second is a reply to the first. Delete both.

## If anything fails

Capture the printed error message and any response body in the error. Note which step failed. File the result in the PR description so future maintainers see what shape of breakage they need to handle.
80 changes: 80 additions & 0 deletions marketing/channels/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# @ngaf/marketing-channels

Channel adapters for the Cacheplane marketing pipeline. One adapter per channel, all behind a single `ChannelAdapter` interface.

## Implemented

- **X** (`getAdapter('x')`) — post single tweets, threads, and image media (PNG ≤ 5MB, alt text required). `metrics()` is a stub until the X tier upgrades to Basic+.

## Planned (follow-up commits in this package — no separate spec)

- Dev.to — next
- LinkedIn
- Reddit

## Quickstart

```ts
import { getAdapter } from '@ngaf/marketing-channels';

const x = getAdapter('x');
const result = await x.post({
channel: 'x',
text: 'Hello from Cacheplane.',
});
console.log(result.url);
```

## Auth (X)

X uses OAuth 2.0 User Context with PKCE. The first time you set it up, run the bootstrapper:

```bash
pnpm marketing:channels:x:auth
```

It opens your browser, you authorize the app, and it prints the tokens for you to paste into `.env`:

```
X_ACCESS_TOKEN=...
X_REFRESH_TOKEN=...
X_USER_HANDLE=brian
```

Prerequisites: create an X v2 app at <https://developer.x.com/en/portal/dashboard> and set the `X_CLIENT_ID` + `X_CLIENT_SECRET` env vars from the app's OAuth 2.0 section.

When an access token expires, the adapter automatically calls `/2/oauth2/token` to refresh and prints the new refresh token to stderr (X rotates refresh tokens on use; update your `.env` for the next process start).

## Dry-run

Set `DRY_RUN=1` and `post()` writes the draft to `marketing/cowork/outbox/dry-runs/<id>.json` instead of hitting any API. Safe for local development and CI.

```bash
DRY_RUN=1 npx tsx marketing/channels/scripts/smoke.ts
```

## Validation

All adapters call `validateDraft()` first. Drafts that violate per-channel rules throw `ValidationError` before any network call. X rules:

- Single tweet OR thread (mutually exclusive).
- Each tweet/part ≤ 280 code points.
- Threads have ≥ 2 parts.
- Up to 4 media items per post.
- PNG only, ≤ 5MB, alt text required (1-1000 chars).

## Adding a new adapter

1. Create `src/<channel>/{index,auth,post}.ts`.
2. Implement `ChannelAdapter`.
3. Add the per-channel rules to `validation.ts`.
4. Wire into `registry.ts:buildAdapter`.
5. Add an entry to this README.
6. Add env vars to `marketing/.env.example`.
7. Tests use `msw/node` to mock the channel's HTTP API.

## See also

- Spec: `docs/superpowers/specs/marketing/2026-05-17-channel-adapters-design.md`
- Meta: `docs/superpowers/specs/marketing/2026-05-17-marketing-meta-design.md`
- Manual smoke recipe: `MANUAL-SMOKE.md`
6 changes: 6 additions & 0 deletions marketing/channels/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
"tsConfig": "marketing/channels/tsconfig.lib.json"
}
},
"test": {
"executor": "@nx/vitest:test",
"options": {
"configFile": "marketing/channels/vite.config.mts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
Expand Down
64 changes: 64 additions & 0 deletions marketing/channels/scripts/smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Standalone smoke runner for the X adapter. NOT exported by the package.
// Usage:
// pnpm marketing:channels:x:auth # one-time, fills .env
// DRY_RUN=1 npx tsx marketing/channels/scripts/smoke.ts
// npx tsx marketing/channels/scripts/smoke.ts
// SMOKE_MEDIA=1 npx tsx marketing/channels/scripts/smoke.ts
// SMOKE_THREAD=1 npx tsx marketing/channels/scripts/smoke.ts

import fs from 'node:fs';
import path from 'node:path';
import { getAdapter, type Draft } from '../src';

// 1x1 transparent PNG.
const PIXEL_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=',
'base64',
);

function buildDraft(): Draft {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
if (process.env.SMOKE_THREAD === '1') {
return {
channel: 'x',
threadParts: [
`Marketing pipeline smoke test — please ignore. (${stamp}) [1/2]`,
'This is the second tweet of the smoke thread. [2/2]',
],
};
}
if (process.env.SMOKE_MEDIA === '1') {
return {
channel: 'x',
text: `Marketing pipeline smoke test with media — please ignore. (${stamp})`,
media: [{ png: PIXEL_PNG, alt: 'A 1x1 transparent pixel — test image.' }],
};
}
return {
channel: 'x',
text: `Marketing pipeline smoke test — please ignore. (${stamp})`,
};
}

async function main(): Promise<void> {
const adapter = getAdapter('x');
const draft = buildDraft();
const result = await adapter.post(draft);
console.log(JSON.stringify(result, null, 2));
if (result.url.startsWith('https://dry-run.local')) {
const outFile = path.join(
process.cwd(),
'marketing',
'cowork',
'outbox',
'dry-runs',
`${result.postId}.json`,
);
if (fs.existsSync(outFile)) console.log(`Dry-run file written: ${outFile}`);
}
}

main().catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
});
65 changes: 65 additions & 0 deletions marketing/channels/src/dry-run.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { writeDryRunResult } from './dry-run';
import type { Draft } from './types';

let cwd: string;
let origCwd: string;

beforeEach(() => {
origCwd = process.cwd();
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'dry-run-test-'));
process.chdir(cwd);
});

afterEach(() => {
process.chdir(origCwd);
fs.rmSync(cwd, { recursive: true, force: true });
});

describe('writeDryRunResult', () => {
it('writes a JSON file under marketing/cowork/outbox/dry-runs and returns a synthetic PostResult', async () => {
const draft: Draft = { channel: 'x', text: 'hello' };
const result = await writeDryRunResult(draft);

expect(result.channel).toBe('x');
expect(result.postId).toMatch(/^dry-[0-9a-f-]{36}$/);
expect(result.url).toBe(`https://dry-run.local/x/${result.postId}`);
expect(typeof result.postedAt).toBe('string');

const outFile = path.join(
cwd,
'marketing',
'cowork',
'outbox',
'dry-runs',
`${result.postId}.json`,
);
expect(fs.existsSync(outFile)).toBe(true);
const parsed = JSON.parse(fs.readFileSync(outFile, 'utf8'));
expect(parsed.draft).toEqual({ channel: 'x', text: 'hello' });
expect(typeof parsed.simulatedAt).toBe('string');
});

it('serializes Buffer media as base64 strings to keep the file portable', async () => {
const draft: Draft = {
channel: 'x',
text: 'hi',
media: [{ png: Buffer.from('hello'), alt: 'h' }],
};
const result = await writeDryRunResult(draft);
const outFile = path.join(
cwd,
'marketing',
'cowork',
'outbox',
'dry-runs',
`${result.postId}.json`,
);
const parsed = JSON.parse(fs.readFileSync(outFile, 'utf8'));
expect(parsed.draft.media[0].png).toBe('aGVsbG8='); // base64('hello')
expect(parsed.draft.media[0].alt).toBe('h');
});
});
37 changes: 37 additions & 0 deletions marketing/channels/src/dry-run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import type { Draft, PostResult } from './types';

function serializeDraft(draft: Draft): unknown {
if (!draft.media || draft.media.length === 0) return draft;
return {
...draft,
media: draft.media.map((m) => ({
png: m.png.toString('base64'),
alt: m.alt,
})),
};
}

export async function writeDryRunResult(draft: Draft): Promise<PostResult> {
const id = `dry-${crypto.randomUUID()}`;
const outDir = path.join(process.cwd(), 'marketing', 'cowork', 'outbox', 'dry-runs');
await fs.mkdir(outDir, { recursive: true });
const file = path.join(outDir, `${id}.json`);
await fs.writeFile(
file,
JSON.stringify(
{ draft: serializeDraft(draft), simulatedAt: new Date().toISOString() },
null,
2,
),
);
return {
channel: draft.channel,
postId: id,
url: `https://dry-run.local/${draft.channel}/${id}`,
postedAt: new Date().toISOString(),
};
}
Loading
Loading