Skill: zakmcintyre/indie-app-marketing-pipeline (version 1.0.0)

--- name: indie-app-marketing-pipeline description: > Template-driven multi-platform content pipeline for indie iOS developers. Generates and schedules a full week of social posts (TikTok, YouTube Shorts, X/Twitter, Facebook) from a content bank with zero LLM calls per day. Use this skill when asked about: marketing pipeline, content pipeline, social media automation, indie app marketing, automated posting, content scheduling. license: MIT metadata: openclaw: emoji: 📲 requires: bins: [node] env: [POSTIZ_API_KEY] --- # Indie App Marketing Pipeline A zero-LLM daily marketing system for indie iOS developers. Write your angles once, then a weekly planner + daily publisher handle the rest — generating platform-native copy and scheduling posts to [Postiz](https://postiz.com) automatically. ## What the Pipeline Does 1. **Weekly Planner** — reads your content bank, picks unused angles, applies platform-specific templates, and writes a `weekly-plan-YYYY-MM-DD.json` file. No LLM required. Run once per week (or whenever you add new angles). 2. **Daily Publisher** — runs each morning (cron at 7 AM), reads that day's entries from the weekly plan, and schedules them to Postiz via its public API. - Text posts (X, Facebook) are scheduled directly. - TikTok/YouTube Shorts trigger your video-gen script (or skip with `--text-only`). The only time you need an LLM is to write new content bank angles when the bank runs dry. ## Supported Platforms | Platform | Post type | Volume | |---|---|---| | TikTok | Video (requires separate video-gen script) | 3/day | | YouTube Shorts | Video (reuses TikTok video) | 3/day | | X / Twitter | Text tweet, 280-char trimmed | 2/day | | Facebook | Text brand post | Mon/Wed/Fri | ## Architecture ``` content-bank.json ← your angles (hook + texts + caption per angle) │ ▼ weekly-planner.js ← picks angles, renders templates → weekly-plan.json │ ▼ weekly-plan-YYYY-MM-DD.json │ ▼ daily-publisher.js ← reads today's posts → Postiz API → live schedule │ ▼ Postiz (postiz.com) ← buffers + publishes to each platform on schedule ``` **Template system** — `content-templates.json` defines patterns per platform (hot-take, question-hook, relatable-meme, etc.). The planner cycles through patterns to avoid repetition. Variables: `{hook}`, `{insight}`, `{cta}`, `{appName}`, `{appHandle}`, `{hashtags}`. **Posting history** — `posting-history.json` tracks which angles have been posted so the planner never repeats content until the bank is exhausted. ## Setup ### 1. Accounts Needed - **Postiz** — self-hosted or cloud ([postiz.com](https://postiz.com)). Free tier works. Connect your TikTok, YouTube, X, and Facebook accounts in the Postiz UI. Note the integration IDs for each platform. - **Social accounts** — TikTok creator, YouTube channel, X account, Facebook page. ### 2. Run the Setup Script ```bash cd /path/to/your-app-marketing/ bash skills/indie-app-marketing-pipeline/scripts/setup.sh ``` The script will: - Create the directory structure (`plans/`, `logs/`, `content-bank.json`, etc.) - Prompt for your Postiz URL, API key, and platform integration IDs - Create a sample content bank with 10 starter angles - Create a `.env` file ### 3. Configure Your App Edit `config.json` (created by setup.sh): ```json { "app": { "name": "YourAppName", "handle": "@YourHandle", "appStoreUrl": "https://apps.apple.com/app/yourapp", "websiteUrl": "https://yourwebsite.com", "topicCategory": "productivity" }, "postiz": { "apiKey": "$POSTIZ_API_KEY", "integrationIds": { "tiktok": "your-tiktok-integration-id", "youtube": "your-youtube-integration-id", "x": "your-x-integration-id", "facebook": "your-facebook-integration-id" } } } ``` ### 4. Edit Your Content Bank Fill `content-bank.json` with angles for your app. See `assets/sample-content-bank.json` for the format and `references/content-bank-guide.md` for strategy. Each angle needs: ```json { "id": "unique-kebab-case-id", "hook": "One punchy sentence that is the hook", "texts": [ "Opening line for the video script", "Second beat", "Third beat / insight", "Close" ], "caption": "TikTok/YouTube caption with hashtags" } ``` ### 5. Run the Weekly Planner ```bash node scripts/weekly-planner.js --dry-run # preview node scripts/weekly-planner.js # write plan node scripts/weekly-planner.js --week 2026-04-07 # specific start date node scripts/weekly-planner.js --days 3 # plan just 3 days ``` ### 6. Run the Daily Publisher ```bash node scripts/daily-publisher.js --dry-run # preview today node scripts/daily-publisher.js # go live node scripts/daily-publisher.js --text-only # skip video, schedule text only node scripts/daily-publisher.js --skip-past # skip posts whose time has passed ``` ### 7. Schedule the Daily Publisher (macOS cron) ```bash # Open crontab crontab -e # Add line (runs at 7:00 AM daily): 0 7 * * * cd /path/to/your-app-marketing && node scripts/daily-publisher.js >> logs/cron.log 2>&1 ``` Or use a launchd plist for more control. ## Customization ### Posting Schedule Edit the `VISUAL_SCHEDULE`, `X_SCHEDULE`, and `FB_SCHEDULE` arrays in `weekly-planner.js`. Times are in EST by default — change `toISOEst()` to use your timezone offset. Default schedule: | Slot | Platform | Time (EST) | |---|---|---| | tiktok-1 | TikTok | 08:00 | | youtube-short-1 | YouTube | 08:15 | | tiktok-2 | TikTok | 13:00 | | youtube-short-2 | YouTube | 13:15 | | tiktok-3 | TikTok | 18:00 | | youtube-short-3 | YouTube | 18:15 | | x-1 | X | 10:30 | | x-2 | X | 16:00 | | fb-text-1 | Facebook | 12:00 (Mon/Wed/Fri) | ### Content Templates Edit `assets/content-templates.json` to customize the copy patterns. The planner rotates through all patterns for each platform. See the template variable reference in `references/platform-strategy.md`. ### Facebook Posts Facebook posts come from a separate `fb-brand-content-bank.json` (richer, longer-form content). The weekly planner picks from this bank for Mon/Wed/Fri slots. See `references/content-bank-guide.md` for how to structure these. ### Angles Per Day Change `ANGLES_PER_DAY` (default: 3) in `weekly-planner.js` to post more or fewer video angles per day. ## Files Reference ``` scripts/ weekly-planner.js ← generate weekly plan daily-publisher.js ← schedule today's posts to Postiz setup.sh ← first-time setup assets/ content-templates.json ← platform copy patterns sample-content-bank.json ← 10 starter angles references/ content-bank-guide.md ← how to write & maintain your angle bank platform-strategy.md ← platform-specific best practices ``` ## References - [content-bank-guide.md](references/content-bank-guide.md) — writing angles that work - [platform-strategy.md](references/platform-strategy.md) — platform-specific tactics - [Postiz docs](https://docs.postiz.com) — API reference

path: assets/content-templates.json
content: {
  "meta": {
    "description": "Platform-specific content templates for the indie app marketing pipeline. The weekly planner rotates through patterns per platform. Variables: {hook}, {insight}, {cta}, {appName}, {appHandle}, {hashtags}, {topic}.",
    "version": "1.0.0"
  },
  "x": {
    "patterns": [
      {
        "id": "hot-take",
        "template": "{hook}.\n\n{insight}\n\n{cta}\n\n{hashtags} {appHandle}"
      },
      {
        "id": "question-hook",
        "template": "honest question: {questionForm}\n\n{insight}\n\n{hashtags} {appHandle}"
      },
      {
        "id": "relatable-meme",
        "template": "me: {relatable}\n\nalso me: {punchline}\n\nevery. single. time.\n\n{hashtags} {appHandle}"
      },
      {
        "id": "dev-perspective",
        "template": "something I keep hearing from people: {hookLower}\n\n{insight}\n\n{hashtags} {appHandle}"
      },
      {
        "id": "quick-thought",
        "template": "{hook}. that's it. that's the tweet.\n\n{hashtags} {appHandle}"
      },
      {
        "id": "tip-thread",
        "template": "{tipIntro}\n\n{tipBody}\n\n{cta}\n\n{hashtags} {appHandle}"
      },
      {
        "id": "story-snippet",
        "template": "{storyLine1} {storyLine2}"
      }
    ]
  },
  "facebook": {
    "patterns": [
      {
        "id": "story-engagement",
        "template": "{storyOpener}\n\n{storyBody}\n\n{engagementQuestion}\n\n{hashtags}"
      },
      {
        "id": "challenge-post",
        "template": "{challengeIntro}\n\n{challengeSteps}\n\n{challengeOutcome}\n\n{engagementQuestion}\n\n{hashtags}"
      },
      {
        "id": "myth-buster",
        "template": "Here's what nobody tells you about {topic}:\n\n{mythBust}\n\n{reframe}\n\n{hashtags}"
      },
      {
        "id": "behind-the-scenes",
        "template": "{btsOpener}\n\n{btsBody}\n\n{engagementQuestion}"
      },
      {
        "id": "tip-of-week",
        "template": "Quick tip for {topic}:\n\n{tipBody}\n\nHave you tried this? What's your approach?\n\n{hashtags}"
      }
    ]
  },
  "youtube": {
    "patterns": [
      {
        "id": "simple-question",
        "template": "{shortQuestion}\n\nBe honest."
      },
      {
        "id": "poll-prompt",
        "template": "{pollQuestion}"
      },
      {
        "id": "challenge-prompt",
        "template": "{challengeDescription}\n\nTry it and tell me how it went."
      },
      {
        "id": "hook-reveal",
        "template": "{hook}\n\n(watch to find out why)"
      }
    ]
  },
  "tiktokStory": {
    "patterns": [
      {
        "id": "3-frame-reveal",
        "frames": [
          "Frame 1:\n{setupLine}",
          "Frame 2:\n{twistLine}",
          "Frame 3:\n{ctaLine}"
        ]
      },
      {
        "id": "problem-solution",
        "frames": [
          "The problem:\n{hook}",
          "What I tried:\n{texts[1]}",
          "What actually worked:\n{texts[2]}"
        ]
      }
    ]
  },
  "appDefaults": {
    "_note": "These are starter defaults. Override via config.json (config.app.*) — the scripts use config.json values, not this section.",
    "exampleApp": {
      "appName": "YourApp",
      "appHandle": "@yourhandle",
      "defaultHashtags": "#YourApp #IndieApp #AppStore #MobileApp",
      "defaultCta": "YourApp — available on the App Store",
      "topicCategory": "productivity"
    }
  }
}


path: assets/sample-content-bank.json
content: [
  {
    "id": "lost-note-frustration",
    "hook": "I lost 4 hours last week looking for a note I took on my phone",
    "texts": [
      "I took a note during a meeting. A good one. Gone.",
      "Searched everywhere — notes app, photos, voice memos, browser history. Nothing.",
      "Turns out I'd left it in a completely different app I forgot I'd used that day.",
      "That was the moment I decided to fix this once and for all."
    ],
    "caption": "If you've ever lost a note you actually needed 😭 #IndieApp #Productivity #AppDev"
  },
  {
    "id": "phone-check-habit",
    "hook": "I checked my phone 94 times yesterday and got almost nothing done",
    "texts": [
      "I turned on screen time tracking as a joke.",
      "94 unlocks. 4.5 hours of screen time. And my to-do list barely moved.",
      "Most of those unlocks were to check something I'd already seen.",
      "The problem isn't willpower — it's that there's no system."
    ],
    "caption": "Screen time numbers don't lie 😬 #Productivity #DigitalWellness #IndieApp"
  },
  {
    "id": "good-ideas-commute",
    "hook": "My best ideas always come during my commute and disappear before I get home",
    "texts": [
      "On the train. Great idea. I'll remember it. I will definitely remember it.",
      "I did not remember it.",
      "I've started treating every commute like a creative session — capture everything, review later.",
      "The ideas didn't get better. I just stopped losing them."
    ],
    "caption": "The commute-to-desk idea graveyard is real 🪦 #IndieApp #AppDev #ProductivityHack"
  },
  {
    "id": "app-store-day-one",
    "hook": "I published my first app and got 3 downloads in the first week — one was my mom",
    "texts": [
      "Year one of indie dev is a masterclass in humility.",
      "3 downloads. 3. And I'd spent 6 months building the thing.",
      "What I didn't know then: the app was fine, the marketing was invisible.",
      "The product is only half the job."
    ],
    "caption": "Indie dev origin story 😅 #IndieAppDev #AppStore #BuildInPublic #iOSDev"
  },
  {
    "id": "sunday-reset-system",
    "hook": "I used to dread Mondays until I figured out the 20-minute Sunday reset",
    "texts": [
      "Sunday evening. Open the app. 20 minutes.",
      "Review what happened last week. Note what carried over. Set 3 priorities for Monday.",
      "Not a full review, not a life audit — just enough to start Monday with direction.",
      "That's it. 20 minutes and Mondays feel completely different."
    ],
    "caption": "20-minute Sunday reset changed my whole week 🔄 #Productivity #SundayReset #IndieApp"
  },
  {
    "id": "meeting-notes-chaos",
    "hook": "My meeting notes are a crime scene — scattered across 4 apps and 3 notebooks",
    "texts": [
      "Google Docs for the important ones. Apple Notes for the quick ones.",
      "Voice memo for when I couldn't type fast enough. Slack DM to myself when I was lazy.",
      "Action items? Somewhere in there. Good luck finding them.",
      "The system that works is the one you'll actually use consistently."
    ],
    "caption": "Meeting notes archaeology 🕵️ #ProductivityHack #MeetingNotes #AppDev"
  },
  {
    "id": "zero-downloads-paid-ads",
    "hook": "I spent $200 on ads and got 2 downloads — here's what I learned",
    "texts": [
      "Ran a campaign. Set it up carefully. Watched the dashboard.",
      "$200 later: 2 downloads, 0 reviews, and a lot of lessons.",
      "The problem wasn't the ad. It was that I had no idea who I was talking to.",
      "Paid traffic amplifies what's already there — organic first."
    ],
    "caption": "Expensive lesson on day 60 of indie dev 💸 #IndieAppDev #AppMarketing #BuildInPublic"
  },
  {
    "id": "shipping-anxiety",
    "hook": "The most stressful part of shipping an app isn't the code — it's the waiting",
    "texts": [
      "App submitted. Review pending. Nothing to do but wait.",
      "You find 3 bugs you want to fix. You can't. It's out of your hands.",
      "Apple approves it. Users download it. And then you hold your breath again.",
      "Shipping never gets less stressful. You just get better at sitting with it."
    ],
    "caption": "The post-submit waiting game 😰 #iOSDev #AppStore #IndieAppDev #SwiftUI"
  },
  {
    "id": "small-wins-compound",
    "hook": "The people I know who are the most productive don't have better tools — they have smaller goals",
    "texts": [
      "I used to set massive weekly goals. I hit maybe half.",
      "I switched to daily goals of 3 things. I hit most of them.",
      "Small wins are underrated. They compound. They build confidence.",
      "Ambition is good. But it works better in small doses, every day."
    ],
    "caption": "Small wins > big goals 🎯 #Productivity #SmallWins #IndieApp #AppDev"
  },
  {
    "id": "indie-dev-why",
    "hook": "People ask why I do indie dev when it would be easier to just work at a company",
    "texts": [
      "Honest answer: it would be easier. The pay, the stability, the perks.",
      "But I'd be building someone else's thing, for someone else's users, toward someone else's goals.",
      "Indie dev is harder. It's also mine.",
      "Every user who finds the app useful — that's a direct line from me to them. No middleman."
    ],
    "caption": "Why indie dev despite everything 🛠️ #IndieAppDev #BuildInPublic #iOSDev #SoloFounder"
  }
]


path: references/content-bank-guide.md
content: # Content Bank Guide

The content bank is the fuel for the entire pipeline. Write strong angles once; the
planner generates a week of cross-platform posts from them automatically.

## What Is an Angle?

An angle is a single perspective or story about your app — the raw material for one
day's worth of TikTok/YouTube videos, plus the X tweets and Facebook post that
accompany them.

A good angle has:
- A **hook** — one punchy sentence that stops the scroll
- **Texts** — 3–5 short lines that expand the hook into a video script
- A **caption** — the TikTok/YouTube caption with hashtags

## File Format

`content-bank.json` is a JSON array of angle objects:

```json
[
  {
    "id": "unique-kebab-id",
    "hook": "One punchy sentence that is the hook",
    "texts": [
      "Opening line — restate or escalate the hook",
      "The problem or insight (1-2 sentences)",
      "The turn or solution",
      "The close / mini-CTA"
    ],
    "caption": "TikTok/YouTube caption. Can include hashtags. #YourApp #IndieApp"
  }
]
```

The `id` must be unique. The planner uses it to track which angles have been posted
and will never repeat an angle until the bank is exhausted.

## Facebook Brand Content Bank

`fb-brand-content-bank.json` is a separate array for the longer-form Facebook posts
(Mon/Wed/Fri). Format:

```json
[
  {
    "id": "fb-unique-id",
    "pattern": "behind-the-scenes",
    "content": "Full post text here. Can be multi-paragraph.\n\nEngagement question at the end."
  }
]
```

Facebook posts don't come from the angle bank — they're independent. Write 10–20 to
start. The planner picks them in order.

## Writing Great Hooks

The hook is the most important part. It should:

- Start a story or raise a question mid-stream ("I spent 3 years doing this wrong")
- State a surprising fact or counter-intuitive truth
- Be relatable to your target user's daily frustration
- Be short enough to read in one breath

**Bad hook:** "Our app helps you be more productive."
**Good hook:** "I lost 4 hours last week looking for a note I took on my phone."

## Writing the Texts Array

Think of this as a 15–30 second video script:

1. **Line 1** — Restate or slightly escalate the hook
2. **Line 2** — The problem. Be specific. Use numbers if you can.
3. **Line 3** — The insight or turn. What changed?
4. **Line 4** — The close. Low-pressure. Can mention the app.

Each line should be 1–2 sentences. The planner uses these to generate X tweets
by pulling specific lines for different tweet patterns.

**Example:**

```json
{
  "id": "lost-notes-frustration",
  "hook": "I lost 4 hours last week looking for a note I took on my phone",
  "texts": [
    "I took a note during a meeting. A good one. Gone.",
    "Searched everywhere — notes app, photos, browser tabs. Nothing.",
    "Turns out I'd voice-noted it on my commute and never reviewed it.",
    "That was the moment I built a better system."
  ],
  "caption": "If you've ever lost a note you actually needed 😭 #ThoughtTrack #Productivity #DevLife"
}
```

## Angle Categories

A healthy bank has a mix of these categories. Aim for 3–4 angles per category
when starting out:

| Category | Description | Example hook |
|---|---|---|
| Pain point | The problem your app solves, without mentioning the app | "I missed a deadline because I forgot I'd written down the date" |
| Frustration | Specific everyday friction | "My notes app has 847 notes and I can't find any of them" |
| Discovery | The "aha moment" when someone found a better way | "I didn't realize how much time I was wasting until I tracked it" |
| Social proof | What users say (anonymized or paraphrased) | "Someone told me they use this during their commute every day" |
| Behind the scenes | Indie dev life, building in public | "I rewrote this feature 4 times before I got it right" |
| Tip / how-to | A concrete tip related to your app's domain | "3 things I do every Sunday that make my week less chaotic" |
| Myth buster | Counter-intuitive take | "Productivity apps don't make you productive — here's what actually does" |
| Relatable meme | Humor + recognition | "Me: I'll remember this later. Also me: I did not remember this." |

## How Many Angles Do You Need?

At 3 angles per day × 7 days = 21 angles per week.

- **Starting out:** Write 30 angles. That's ~6 weeks of content.
- **Maintenance:** Add 20–30 new angles every 4–5 weeks.
- **The planner alerts you** when angles run low.

## Keeping the Bank Healthy

### When to Refresh

Run the planner with `--dry-run` to see how many angles remain:

```bash
node scripts/weekly-planner.js --dry-run
```

The output shows: `Available: N unused angles (M total)`

When you have fewer than 21 unused angles (one week's worth), start writing new ones.

### How to Add Angles

Just append to `content-bank.json`. New angles get picked up on the next planner run.

### Recycling

The planner never repeats an angle until ALL angles have been used. Once all are
exhausted, reset `posting-history.json`:

```bash
echo '{"posted":[],"postedFbBrand":[],"lastApp":null}' > posting-history.json
```

Then run the planner again.

### Tracking Performance

After each week, check which angles drove the most engagement. Keep the
high-performers in your reference doc for writing new angles in the same vein.

## Tips for Indie App Devs

1. **Write from experience** — Your personal frustrations are your best hooks.
   Audiences respond to specific, authentic observations.

2. **One idea per angle** — Don't try to say everything. Each angle explores
   a single facet of the problem your app solves.

3. **Don't mention the app too early** — Build curiosity first. The app is the
   solution to the problem you've described — introduce it at the end, not the top.

4. **Vary the emotional tone** — Mix frustration, humor, discovery, and insight.
   A bank full of only "pain point" angles feels repetitive.

5. **Write in batches** — 30 angles in one sitting is easier than 3 angles every
   few days. Schedule a "content writing" block once a month.

6. **Use your App Store reviews** — User reviews are pre-written hooks. Paraphrase
   the common themes into angles.


path: references/platform-strategy.md
content: # Platform Strategy

Platform-specific best practices for the indie app marketing pipeline.

## TikTok

### Why TikTok First

TikTok's algorithm is the most discovery-friendly for new accounts. A well-executed
video can reach thousands of non-followers. It's the highest-leverage platform for
an indie dev starting from zero.

The pipeline posts 3 videos/day at 08:00, 13:00, 18:00. YouTube Shorts reuses the
same video 15 minutes later.

### What Works

- **Hook in the first 2 seconds** — TikTok measures "2-second view rate." Start with
  your hook as on-screen text before you say anything.
- **9:16 vertical, 1080×1920** — Full-screen. Any other ratio gets letterboxed.
- **15–45 seconds** — Shorter completion rates are better. 30s is the sweet spot.
- **Text overlays** — Many viewers watch without sound. Always caption key points.
- **Loop-friendly ending** — Ending that flows back into the beginning increases
  replay rate, which is a strong ranking signal.

### Hashtag Strategy

Use 3–5 hashtags. Mix:
- 1 branded tag (`#YourAppName`)
- 1–2 niche tags for your domain (`#ProductivityHack`, `#IndieApp`)
- 1 broad tag (`#AppDev`, `#TechTok`)

Avoid using only huge hashtags (millions of posts) — your video gets buried.
Avoid using only tiny hashtags — no audience to reach.

### Caption Format

The caption is secondary on TikTok — most viewers don't read it — but it helps
search and SEO. Keep it under 150 characters. Put hashtags at the end.

### Template Variables for TikTok

The pipeline uses the `tiktokStory.3-frame-reveal` template for structure:

- `{setupLine}` — The hook restated as an on-screen title
- `{twistLine}` — The insight or turn
- `{ctaLine}` — App name or soft CTA

---

## YouTube Shorts

### Reuse Strategy

The pipeline reuses the TikTok video for YouTube Shorts (same file, posted 15 minutes
after TikTok). This is intentional — both platforms prefer 9:16 video and the content
performs equivalently.

### What Differs

- **Title matters more** — YouTube uses the caption as the title in search. Write
  captions that read as search queries: "How I stopped losing ideas on my phone"
  not "this changed everything 😮"
- **Description field** — YouTube gives you more space. Add the App Store link here.
- **Community** — YouTube Shorts viewers are more likely to subscribe and watch
  longer content later. Good for building an audience if you plan regular videos.

### Template Variables

YouTube Shorts uses the `youtube` templates:
- `simple-question` — `{shortQuestion}` + "Be honest."
- `poll-prompt` — `{pollQuestion}`
- `challenge-prompt` — `{challengeDescription}` + "Try it and tell me how it went."

---

## X / Twitter

### Tone

X works best when it reads like a real person talking, not a brand posting.

The pipeline generates tweets using natural-voice patterns:
- **Observation** — stating something you noticed, then expanding on it
- **Dev perspective** — "Something I keep hearing from people: [hook]"
- **Quick thought** — "[Hook]. That's it. That's the tweet."
- **Engagement question** — "Be honest: [rephrased hook as question]?"
- **Story snippet** — two lines from the angle's texts joined together

### Link Placement

The pipeline alternates between two link types:
- Even tweets include the App Store link
- Odd tweets use the website URL (or no link, for purely engagement-focused tweets)

This avoids every tweet looking like an ad.

### Hashtag Strategy on X

Use 2–3 hashtags maximum. On X, more than 3 hashtags looks spammy and hurts
organic reach. The pipeline uses:
- 1–2 trending tags from `hashtag-bank.json` (if populated)
- 1–2 evergreen tags from the bank or config defaults

### Character Count

The pipeline trims tweets to 280 characters automatically while preserving the
last line (link or hashtags). Tweets that exceed 280 chars have their body truncated.

### Posting Schedule

- x-1: 10:30 — morning engagement window
- x-2: 16:00 — afternoon re-engagement

### Template Variables

```
{hook}        — the angle's hook sentence
{insight}     — 1-2 lines from texts[] expanded
{cta}         — config.app.defaultCta
{appHandle}   — config.app.handle
{hashtags}    — resolved from hashtag-bank.json or defaults
{topic}       — config.app.topicCategory
```

---

## Facebook

### Why Facebook

Facebook is different from the other platforms — it's not for discovery, it's for
community. Your Facebook posts go to people who already follow your page.

The pipeline posts on Mon/Wed/Fri at 12:00 (configurable). 3 posts/week is the
right cadence for a brand page without being annoying.

### Content Style

Facebook posts work best when they:
- Tell a short story or share a behind-the-scenes moment
- End with a genuine engagement question (not "like and share!")
- Are longer than a tweet but not essay-length (100–250 words)
- Feel personal and authentic, not corporate

Facebook audiences are more tolerant of imperfection and vulnerability than
TikTok or X audiences. Use that.

### Facebook Content Bank (`fb-brand-content-bank.json`)

Facebook posts come from a separate bank (not the same angles as TikTok/YouTube).
This is intentional — Facebook wants different content.

Good patterns for Facebook brand posts:

| Pattern | Description |
|---|---|
| `behind-the-scenes` | What happened this week building the app |
| `user-story` | Anonymous user feedback or outcome (with permission) |
| `tip` | One practical tip related to your app's domain |
| `myth-buster` | Counter-intuitive truth about your topic area |
| `challenge` | Invite followers to try something for a week |
| `personal-update` | Something you learned, struggled with, or fixed |

### Template Variables

```
{storyOpener}         — opening sentence that draws the reader in
{storyBody}           — the main content (2–3 paragraphs)
{engagementQuestion}  — the closing question for comments
{hashtags}            — 3–5 hashtags (less important on Facebook)
{challengeIntro}      — for challenge-post pattern
{challengeSteps}      — numbered steps
{challengeOutcome}    — what they'll get
{topic}               — your app's topic area
{mythBust}            — the myth being busted
{reframe}             — the better framing
```

---

## Cross-Platform Timing

Default schedule (all times configurable via `config.json`):

```
08:00  TikTok #1
08:15  YouTube Short #1  (same video)
10:30  X tweet #1
12:00  Facebook (Mon/Wed/Fri only)
13:00  TikTok #2
13:15  YouTube Short #2
16:00  X tweet #2
18:00  TikTok #3
18:15  YouTube Short #3
```

All times use the timezone offset in `config.json` (`schedule.timezoneOffset`).
Default is EST (`-05:00`). For PST use `-08:00`, for UTC use `+00:00`.

### Time Slot Rationale

- **8 AM, 1 PM, 6 PM** — standard TikTok high-engagement windows (commute, lunch, evening)
- **15 min after TikTok** — YouTube Shorts can use the same video file; slight delay
  avoids exact same-second posting which can look robotic
- **10:30 AM X** — morning engagement before lunch
- **4 PM X** — afternoon slump, people check their phones
- **12 PM Facebook** — peak Facebook engagement on weekdays

## Hashtag Bank (`hashtag-bank.json`)

Optional but recommended. Update weekly with trending tags in your niche.

Format:
```json
{
  "trending": [
    { "tag": "#TrendingTag", "addedAt": "2026-03-21" }
  ],
  "evergreen": [
    "#YourApp", "#IndieApp", "#AppStore", "#MobileApp"
  ]
}
```

The weekly planner pulls from this bank for X tweets. If the file doesn't exist,
it falls back to `config.app.defaultHashtags`.


path: scripts/daily-publisher.js
content: #!/usr/bin/env node
/**
 * Daily Publisher — Indie App Marketing Pipeline
 *
 * Reads today's entries from the weekly plan and schedules them to Postiz.
 *
 * Handles:
 *   - X tweets      → schedules text via Postiz API
 *   - Facebook posts → schedules text via Postiz API
 *   - TikTok         → triggers your video-gen script (requires requiresVideoGen flag)
 *   - YouTube Shorts → marks as handled via TikTok crossPost
 *
 * Usage:
 *   node daily-publisher.js [--dry-run] [--day YYYY-MM-DD] [--skip-past] [--text-only] [--config path/to/config.json]
 *
 * --dry-run:   Preview without making any API calls
 * --day:       Override today's date (default: today)
 * --skip-past: Skip posts whose scheduled time has already passed
 * --text-only: Only schedule X + Facebook; skip TikTok + YouTube
 * --config:    Path to config.json (default: ./config.json)
 *
 * Designed to run on a cron at 7:00 AM daily.
 */

'use strict';

const fs             = require('fs');
const path           = require('path');
const { execSync }   = require('child_process');

// ── CLI ──────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);

function getArg(name) {
  const idx = args.indexOf(`--${name}`);
  return idx !== -1 ? args[idx + 1] : null;
}

const dryRun   = args.includes('--dry-run');
const skipPast = args.includes('--skip-past');
const textOnly = args.includes('--text-only');
const configArg = getArg('config');

// ── Config ───────────────────────────────────────────────────────────────────
const CONFIG_PATH = configArg
  ? path.resolve(configArg)
  : path.join(process.cwd(), 'config.json');

if (!fs.existsSync(CONFIG_PATH)) {
  console.error(`Config not found: ${CONFIG_PATH}`);
  console.error('Run setup.sh first, or pass --config /path/to/config.json');
  process.exit(1);
}

const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));

const BASE_DIR  = path.dirname(CONFIG_PATH);
const PLANS_DIR = path.join(BASE_DIR, config.paths?.plans || 'plans');
const LOGS_DIR  = path.join(BASE_DIR, config.paths?.logs  || 'logs');

// ── .env loader ──────────────────────────────────────────────────────────────
function loadEnv(dir) {
  const envPath = path.join(dir, '.env');
  if (!fs.existsSync(envPath)) return;
  for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) {
    const t = line.trim();
    if (!t || t.startsWith('#')) continue;
    const eq = t.indexOf('=');
    if (eq === -1) continue;
    const key = t.slice(0, eq).trim();
    let val   = t.slice(eq + 1).trim();
    if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
      val = val.slice(1, -1);
    }
    if (!process.env[key]) process.env[key] = val;
  }
}

loadEnv(BASE_DIR);

// ── Postiz setup ──────────────────────────────────────────────────────────────
const POSTIZ_URL = (() => {
  const raw = config.postiz?.url || 'https://api.postiz.com/public/v1';
  return raw.replace(/\/$/, ''); // strip trailing slash
})();

const POSTIZ_KEY = (() => {
  const raw = config.postiz?.apiKey || '';
  if (raw.startsWith('$')) return process.env[raw.slice(1)] || '';
  return raw || process.env.POSTIZ_API_KEY || '';
})();

const INTEGRATION_IDS = config.postiz?.integrationIds || {};

if (!POSTIZ_KEY && !dryRun) {
  console.error('ERROR: No Postiz API key. Set POSTIZ_API_KEY in .env or config.json.');
  process.exit(1);
}

// Platforms that use direct text scheduling via Postiz
const TEXT_PLATFORMS = new Set(config.textPlatforms || ['x', 'facebook']);

// ── Find plan that covers a given date ───────────────────────────────────────
// When multiple plans cover the same date, prefer the one with the latest weekStart.
function findPlanForDate(targetDate) {
  if (!fs.existsSync(PLANS_DIR)) return null;

  const files = fs.readdirSync(PLANS_DIR)
    .filter(f => f.startsWith('weekly-plan-') && f.endsWith('.json'));

  let bestPath      = null;
  let bestWeekStart = '';

  for (const f of files) {
    const planPath = path.join(PLANS_DIR, f);
    try {
      const plan = JSON.parse(fs.readFileSync(planPath, 'utf-8'));
      if (
        plan.meta?.weekStart <= targetDate &&
        plan.meta?.weekEnd   >= targetDate &&
        plan.meta.weekStart > bestWeekStart
      ) {
        bestWeekStart = plan.meta.weekStart;
        bestPath      = planPath;
      }
    } catch { /* skip malformed plans */ }
  }

  return bestPath;
}

// ── Platform settings for Postiz ─────────────────────────────────────────────
function getSettings(platform) {
  switch (platform) {
    case 'x':        return { __type: 'x', who_can_reply_post: 'everyone' };
    case 'facebook': return { __type: 'facebook' };
    default:         return { __type: platform };
  }
}

// ── Retry helper (handles 429 rate limits and 5xx server errors) ──────────────
async function withRetry(fn, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const is429 = err.message.includes('429');
      const is5xx = /Postiz 5\d\d/.test(err.message);
      if ((is429 || is5xx) && attempt < maxRetries) {
        const wait = (attempt + 1) * (is429 ? 10000 : 5000);
        console.log(`  ${is429 ? 'Rate limited' : 'Server error'}, waiting ${wait / 1000}s (attempt ${attempt + 1}/${maxRetries})...`);
        await new Promise(r => setTimeout(r, wait));
        continue;
      }
      throw err;
    }
  }
}

// ── Schedule a text post to Postiz ───────────────────────────────────────────
async function scheduleTextPost(post) {
  const integrationId = INTEGRATION_IDS[post.platform];
  if (!integrationId) {
    throw new Error(`No integration ID configured for platform: ${post.platform}. Add it to config.json.`);
  }

  const payload = {
    type:      'schedule',
    date:      new Date(post.scheduledTime).toISOString(),
    shortLink: false,
    tags:      [],
    posts: [{
      integration: { id: integrationId },
      value:       [{ content: post.content, image: [] }],
      settings:    getSettings(post.platform),
    }],
  };

  const res = await fetch(`${POSTIZ_URL}/posts`, {
    method:  'POST',
    headers: {
      'Authorization': POSTIZ_KEY,
      'Content-Type':  'application/json',
    },
    body: JSON.stringify(payload),
  });

  const responseText = await res.text();
  let responseData;
  try { responseData = JSON.parse(responseText); } catch { responseData = responseText; }

  if (!res.ok) {
    throw new Error(`Postiz ${res.status}: ${typeof responseData === 'string' ? responseData : JSON.stringify(responseData)}`);
  }

  return responseData;
}

// ── Trigger video generation ──────────────────────────────────────────────────
// Calls the script defined in config.videoGen.script with angle + schedule args.
// If no script is configured, logs a TODO and marks as skipped.
function triggerVideoGen(post) {
  const videoGenScript = config.videoGen?.script
    ? path.resolve(BASE_DIR, config.videoGen.script)
    : null;

  if (!videoGenScript || !fs.existsSync(videoGenScript)) {
    console.log(`  No video-gen script configured. Set config.videoGen.script to enable.`);
    console.log(`  TODO: generate video for angle "${post.hook}" and post to TikTok at ${post.scheduledTime}`);
    return { ok: false, skipped: true };
  }

  const scheduleArg = post.scheduledTime ? `--schedule "${post.scheduledTime}"` : '';
  const angleArg    = post.angleId       ? `--angle ${post.angleId}`            : '';
  const cmd         = `node "${videoGenScript}" ${angleArg} ${scheduleArg}`.trim();

  console.log(`  Running: ${cmd}`);

  try {
    const output = execSync(cmd, {
      cwd:       BASE_DIR,
      timeout:   600000,    // 10 min for image/video gen
      stdio:     ['ignore', 'pipe', 'pipe'],
      maxBuffer: 10 * 1024 * 1024,
    });
    process.stdout.write(output.toString());
    return { ok: true };
  } catch (err) {
    const stderr = err.stderr ? err.stderr.toString() : '';
    const stdout = err.stdout ? err.stdout.toString() : '';
    process.stdout.write(stdout);
    process.stderr.write(stderr);
    console.error(`  Video gen failed: ${err.message}`);
    return { ok: false };
  }
}

// ── Main ──────────────────────────────────────────────────────────────────────
(async () => {
  const today = getArg('day') || new Date().toISOString().slice(0, 10);
  const now   = new Date();

  console.log('='.repeat(60));
  console.log('DAILY PUBLISHER — Indie App Marketing Pipeline');
  console.log('='.repeat(60));
  console.log(`App:  ${config.app?.name || 'unknown'}`);
  console.log(`Date: ${today}`);
  console.log(`Mode: ${dryRun ? 'DRY RUN' : 'LIVE'}${textOnly ? ' (text only)' : ''}\n`);

  // Find plan
  const planPath = findPlanForDate(today);
  if (!planPath) {
    console.error(`No weekly plan found for ${today}. Run weekly-planner.js first.`);
    process.exit(1);
  }

  const plan = JSON.parse(fs.readFileSync(planPath, 'utf-8'));
  console.log(`Using plan: ${path.basename(planPath)}`);
  console.log(`Plan period: ${plan.meta.weekStart} — ${plan.meta.weekEnd}\n`);

  // Get today's posts
  const todayData = plan.days.find(d => d.day === today);
  if (!todayData) {
    console.log(`No posts planned for ${today}.`);
    process.exit(0);
  }

  let todayPosts = todayData.posts;

  if (textOnly) {
    todayPosts = todayPosts.filter(p => TEXT_PLATFORMS.has(p.platform));
  }

  if (skipPast) {
    const before  = todayPosts.length;
    todayPosts    = todayPosts.filter(p => new Date(p.scheduledTime) > now);
    const skipped = before - todayPosts.length;
    if (skipped > 0) console.log(`Skipped ${skipped} past-time post(s)`);
  }

  if (todayPosts.length === 0) {
    console.log('No posts to schedule for today.');
    process.exit(0);
  }

  todayPosts.sort((a, b) => new Date(a.scheduledTime) - new Date(b.scheduledTime));

  // Display summary
  const byPlatform = {};
  for (const p of todayPosts) {
    byPlatform[p.platform] = (byPlatform[p.platform] || 0) + 1;
  }
  console.log(`Today's posts: ${todayPosts.length} total`);
  for (const [plat, count] of Object.entries(byPlatform)) {
    console.log(`  ${plat}: ${count}`);
  }
  console.log('');

  const results = [];

  for (let i = 0; i < todayPosts.length; i++) {
    const post    = todayPosts[i];
    const timeStr = post.scheduledTimeReadable || new Date(post.scheduledTime).toLocaleTimeString();
    const preview = (post.content || post.hook || post.caption || '').split('\n')[0].substring(0, 60);

    console.log(`[${i + 1}/${todayPosts.length}] ${post.platform.toUpperCase()} | ${post.slot} | ${timeStr}`);
    if (preview) console.log(`  "${preview}"`);

    if (dryRun) {
      console.log(`  [DRY RUN] Would schedule\n`);
      results.push({ id: post.id, platform: post.platform, status: 'dry-run', slot: post.slot });
      continue;
    }

    // ── Text posts (X + Facebook) ──
    if (TEXT_PLATFORMS.has(post.platform) && post.content) {
      try {
        const response = await withRetry(() => scheduleTextPost(post));
        const postizId = response?.id || response?._id || response?.postId
          || (Array.isArray(response) && response[0]?.postId);

        console.log(`  Scheduled! Postiz ID: ${postizId || '(no id in response)'}\n`);
        results.push({ id: post.id, platform: post.platform, status: 'scheduled', slot: post.slot, postizId });
      } catch (err) {
        console.error(`  FAILED: ${err.message}\n`);
        results.push({ id: post.id, platform: post.platform, status: 'failed', slot: post.slot, error: err.message });
      }

    // ── TikTok (video gen) ──
    } else if (post.platform === 'tiktok' && post.requiresVideoGen) {
      const MAX_VIDEO_RETRIES = 2;
      let videoResult = null;

      for (let vRetry = 0; vRetry <= MAX_VIDEO_RETRIES; vRetry++) {
        if (vRetry > 0) {
          const wait = vRetry * 60000;
          console.log(`  Video gen retry ${vRetry}/${MAX_VIDEO_RETRIES} — waiting ${wait / 1000}s...`);
          await new Promise(r => setTimeout(r, wait));
        }
        videoResult = triggerVideoGen(post);
        if (videoResult.ok) break;
      }

      const { ok, skipped } = videoResult;
      if (skipped) {
        results.push({ id: post.id, platform: post.platform, status: 'skipped', slot: post.slot });
        console.log('');
      } else {
        results.push({ id: post.id, platform: post.platform, status: ok ? 'video-gen-ok' : 'video-gen-failed', slot: post.slot });
        console.log(`  Video gen: ${ok ? 'OK' : `FAILED after ${MAX_VIDEO_RETRIES + 1} attempts`}\n`);
      }

    // ── YouTube Shorts (handled via TikTok crossPost) ──
    } else if (post.platform === 'youtube' && post.reusesVideo) {
      console.log(`  Handled via TikTok crossPost\n`);
      results.push({ id: post.id, platform: post.platform, status: 'handled-via-crosspost', slot: post.slot });

    } else {
      console.log(`  Skipped (unhandled type: ${post.platform}/${post.type})\n`);
      results.push({ id: post.id, platform: post.platform, status: 'skipped', slot: post.slot });
    }

    // Brief pause between API calls
    if (i < todayPosts.length - 1 && !dryRun) {
      await new Promise(r => setTimeout(r, 2000));
    }
  }

  // ── Results summary ──
  const scheduled  = results.filter(r => r.status === 'scheduled').length;
  const failed     = results.filter(r => r.status === 'failed').length;
  const videoOk    = results.filter(r => r.status === 'video-gen-ok').length;
  const videoFail  = results.filter(r => r.status === 'video-gen-failed').length;

  const statusIcons = {
    'scheduled':           '[OK]',
    'dry-run':             '[DRY]',
    'failed':              '[FAIL]',
    'video-gen-ok':        '[VID]',
    'video-gen-failed':    '[VFAIL]',
    'handled-via-crosspost': '[XPOST]',
    'skipped':             '[SKIP]',
  };

  console.log('='.repeat(60));
  console.log(`RESULTS: ${scheduled} scheduled, ${videoOk} video OK, ${failed + videoFail} failed, ${results.length} total`);
  for (const r of results) {
    console.log(`  ${statusIcons[r.status] || '[?]'} ${r.platform.padEnd(10)} ${r.slot}`);
  }

  // ── Write log ──
  if (!dryRun) {
    fs.mkdirSync(LOGS_DIR, { recursive: true });
    const logPath = path.join(LOGS_DIR, `${today}.json`);
    fs.writeFileSync(logPath, JSON.stringify({
      date:      today,
      app:       config.app?.name,
      results,
      timestamp: new Date().toISOString(),
      version:   'indie-app-marketing-pipeline-v1',
    }, null, 2));
    console.log(`\nLog: ${logPath}`);
  }

  const totalFailed = failed + videoFail;
  if (totalFailed > 0) process.exit(1);
})();


path: scripts/setup.sh
content: #!/usr/bin/env bash
# setup.sh — Indie App Marketing Pipeline
#
# Run from the directory where you want to set up the pipeline:
#   bash /path/to/skills/indie-app-marketing-pipeline/scripts/setup.sh
#
# What it does:
#   1. Creates directory structure (plans/, logs/)
#   2. Copies template files (content-templates.json, sample content bank)
#   3. Prompts for env vars and writes .env + config.json
#   4. Creates posting-history.json
#   5. Creates a sample fb-brand-content-bank.json

set -euo pipefail

SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TARGET_DIR="${1:-$(pwd)}"

# ── Colors ──────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color

echo ""
echo "=================================================="
echo " Indie App Marketing Pipeline — Setup"
echo "=================================================="
echo "Installing into: $TARGET_DIR"
echo ""

# ── Prompt helper ────────────────────────────────────────────────────────────
prompt() {
  local label="$1"
  local default="$2"
  local varname="$3"

  if [ -n "$default" ]; then
    printf "${CYAN}%s${NC} [%s]: " "$label" "$default"
  else
    printf "${CYAN}%s${NC}: " "$label"
  fi

  read -r input
  if [ -z "$input" ] && [ -n "$default" ]; then
    eval "$varname='$default'"
  else
    eval "$varname='$input'"
  fi
}

# ── Gather app info ──────────────────────────────────────────────────────────
echo "── App Info ───────────────────────────────────────"
prompt "App name (e.g. MyApp)"          ""          APP_NAME
prompt "Social handle (e.g. @myapp)"   "@${APP_NAME,,}" APP_HANDLE
prompt "App Store URL"                 ""          APP_STORE_URL
prompt "Website URL"                   "$APP_STORE_URL" WEBSITE_URL
prompt "Topic category (e.g. productivity, finance, health)" "productivity" TOPIC_CATEGORY
prompt "Default hashtags (space-separated)" "#${APP_NAME} #IndieApp #AppStore" DEFAULT_HASHTAGS
prompt "Default CTA"                   "${APP_NAME} — available on the App Store" DEFAULT_CTA

echo ""
echo "── Postiz Setup ────────────────────────────────────"
echo "  Get your API key from: https://app.postiz.com/settings (or your self-hosted instance)"
prompt "Postiz API URL"                "https://api.postiz.com/public/v1" POSTIZ_URL
prompt "Postiz API key"                ""          POSTIZ_KEY

echo ""
echo "  Integration IDs: in Postiz UI → Settings → Integrations → copy the ID for each platform"
echo "  Leave blank if you haven't connected that platform yet."
prompt "TikTok integration ID"         ""          TIKTOK_INTEGRATION_ID
prompt "YouTube integration ID"        ""          YOUTUBE_INTEGRATION_ID
prompt "X / Twitter integration ID"    ""          X_INTEGRATION_ID
prompt "Facebook integration ID"       ""          FB_INTEGRATION_ID

echo ""
echo "── Timezone ─────────────────────────────────────────"
prompt "Timezone offset (e.g. -05:00 for EST, -08:00 for PST, +00:00 for UTC)" "-05:00" TZ_OFFSET

# ── Create directories ────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}Creating directories...${NC}"
mkdir -p "$TARGET_DIR/plans"
mkdir -p "$TARGET_DIR/logs"
echo "  ✓ plans/"
echo "  ✓ logs/"

# ── Copy template files ───────────────────────────────────────────────────────
echo -e "${GREEN}Copying template files...${NC}"

if [ ! -f "$TARGET_DIR/content-templates.json" ]; then
  cp "$SKILL_DIR/assets/content-templates.json" "$TARGET_DIR/content-templates.json"
  echo "  ✓ content-templates.json"
else
  echo "  → content-templates.json already exists, skipping"
fi

if [ ! -f "$TARGET_DIR/content-bank.json" ]; then
  cp "$SKILL_DIR/assets/sample-content-bank.json" "$TARGET_DIR/content-bank.json"
  echo "  ✓ content-bank.json (sample — replace with your angles!)"
else
  echo "  → content-bank.json already exists, skipping"
fi

# ── Write posting-history.json ───────────────────────────────────────────────
if [ ! -f "$TARGET_DIR/posting-history.json" ]; then
  cat > "$TARGET_DIR/posting-history.json" << 'EOF'
{
  "posted": [],
  "postedFbBrand": [],
  "lastApp": null,
  "_note": "Tracks which content bank angles have been used. Do not edit manually."
}
EOF
  echo "  ✓ posting-history.json"
else
  echo "  → posting-history.json already exists, skipping"
fi

# ── Write sample fb-brand-content-bank.json ──────────────────────────────────
if [ ! -f "$TARGET_DIR/fb-brand-content-bank.json" ]; then
  cat > "$TARGET_DIR/fb-brand-content-bank.json" << FBEOF
[
  {
    "id": "fb-behind-scenes-1",
    "pattern": "behind-the-scenes",
    "content": "Building ${APP_NAME} as a solo developer means wearing every hat.\n\nThis week I fixed a bug that took 3 days to track down, redesigned the onboarding flow based on user feedback, and shipped a new feature I've been excited about for months.\n\nThe indie dev life is chaotic and exhausting and I wouldn't trade it.\n\nWhat's the hardest part of your work week? Drop it below."
  },
  {
    "id": "fb-user-story-1",
    "pattern": "user-story",
    "content": "Got a message from a user this week that made my whole month.\n\nThey said ${APP_NAME} helped them finally get on top of something they'd been struggling with for years.\n\nThat's the whole reason I build this stuff. Not the downloads, not the revenue — that message.\n\nIf you've got a moment, I'd love to hear how you're using ${APP_NAME}. Reply here or hit the feedback button in the app."
  },
  {
    "id": "fb-tip-1",
    "pattern": "tip",
    "content": "Quick tip for getting the most out of ${APP_NAME}:\n\nStart small. Most people try to do everything at once and then burn out.\n\nPick one thing. Do that one thing consistently for a week. Then add another.\n\nThe app is designed around this idea — small wins compound.\n\nWhat's one thing you're working on right now?"
  }
]
FBEOF
  echo "  ✓ fb-brand-content-bank.json (3 sample posts)"
else
  echo "  → fb-brand-content-bank.json already exists, skipping"
fi

# ── Write .env ────────────────────────────────────────────────────────────────
echo -e "${GREEN}Writing .env...${NC}"
cat > "$TARGET_DIR/.env" << ENVEOF
# Indie App Marketing Pipeline — environment variables
# Generated by setup.sh

POSTIZ_API_KEY=${POSTIZ_KEY}
ENVEOF
echo "  ✓ .env"

# ── Write config.json ─────────────────────────────────────────────────────────
echo -e "${GREEN}Writing config.json...${NC}"
cat > "$TARGET_DIR/config.json" << CONFIGEOF
{
  "app": {
    "name":             "${APP_NAME}",
    "handle":           "${APP_HANDLE}",
    "appStoreUrl":      "${APP_STORE_URL}",
    "websiteUrl":       "${WEBSITE_URL}",
    "topicCategory":    "${TOPIC_CATEGORY}",
    "defaultHashtags":  "${DEFAULT_HASHTAGS}",
    "defaultCta":       "${DEFAULT_CTA}"
  },
  "postiz": {
    "url":    "${POSTIZ_URL}",
    "apiKey": "\$POSTIZ_API_KEY",
    "integrationIds": {
      "tiktok":    "${TIKTOK_INTEGRATION_ID}",
      "youtube":   "${YOUTUBE_INTEGRATION_ID}",
      "x":         "${X_INTEGRATION_ID}",
      "facebook":  "${FB_INTEGRATION_ID}"
    }
  },
  "schedule": {
    "timezoneOffset": "${TZ_OFFSET}",
    "anglesPerDay": 3,
    "fbDays": [1, 3, 5]
  },
  "paths": {
    "plans":         "plans",
    "logs":          "logs",
    "contentBank":   "content-bank.json",
    "fbContentBank": "fb-brand-content-bank.json",
    "history":       "posting-history.json",
    "templates":     "content-templates.json"
  },
  "videoGen": {
    "script": null,
    "_note": "Set to path of your video gen script, relative to this config.json. Leave null to use --text-only mode."
  }
}
CONFIGEOF
echo "  ✓ config.json"

# ── Done ───────────────────────────────────────────────────────────────────────
echo ""
echo "=================================================="
echo -e "${GREEN} Setup complete!${NC}"
echo "=================================================="
echo ""
echo "Next steps:"
echo ""
echo "  1. Edit content-bank.json with your app's angles"
echo "     → See references/content-bank-guide.md for strategy"
echo ""
echo "  2. Run a dry-run to preview the first week:"
echo "     node scripts/weekly-planner.js --dry-run"
echo ""
echo "  3. Generate the real plan:"
echo "     node scripts/weekly-planner.js"
echo ""
echo "  4. Test the publisher:"
echo "     node scripts/daily-publisher.js --dry-run"
echo ""
echo "  5. Schedule the daily publisher at 7 AM:"
echo "     crontab -e"
echo "     # Add: 0 7 * * * cd $TARGET_DIR && node scripts/daily-publisher.js >> logs/cron.log 2>&1"
echo ""
echo -e "${YELLOW}  NOTE: content-bank.json contains sample angles.${NC}"
echo -e "${YELLOW}  Replace them with angles specific to your app.${NC}"
echo ""


path: scripts/weekly-planner.js
content: #!/usr/bin/env node
/**
 * Weekly Content Planner — Indie App Marketing Pipeline
 *
 * Generates a full week's content plan from a content bank + templates.
 * No LLM calls needed — all content is derived from existing angles via templates.
 *
 * Platforms: TikTok (3/day), YouTube Shorts (3/day), X/Twitter (2/day), Facebook (Mon/Wed/Fri)
 *
 * Output: plans/weekly-plan-YYYY-MM-DD.json
 *
 * Usage:
 *   node weekly-planner.js [--week YYYY-MM-DD] [--dry-run] [--days N] [--config path/to/config.json]
 *
 * --week:    Start date for the plan (default: next Monday, or today if --days is used)
 * --dry-run: Preview without writing plan file
 * --days:    Number of days to plan (default: 7)
 * --config:  Path to config.json (default: ./config.json)
 */

'use strict';

const fs   = require('fs');
const path = require('path');

// ── CLI ──────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);

function getArg(name) {
  const idx = args.indexOf(`--${name}`);
  return idx !== -1 ? args[idx + 1] : null;
}

const dryRun    = args.includes('--dry-run');
const customDays = getArg('days') ? parseInt(getArg('days'), 10) : null;
const configArg  = getArg('config');

// ── Config ───────────────────────────────────────────────────────────────────
const CONFIG_PATH = configArg
  ? path.resolve(configArg)
  : path.join(process.cwd(), 'config.json');

if (!fs.existsSync(CONFIG_PATH)) {
  console.error(`Config not found: ${CONFIG_PATH}`);
  console.error('Run setup.sh first, or pass --config /path/to/config.json');
  process.exit(1);
}

const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));

// App settings — all values come from config, nothing hardcoded
const APP_NAME     = config.app?.name     || 'MyApp';
const APP_HANDLE   = config.app?.handle   || '@myapp';
const APP_STORE_URL = config.app?.appStoreUrl || '';
const WEBSITE_URL   = config.app?.websiteUrl  || APP_STORE_URL;
const TOPIC_CATEGORY = config.app?.topicCategory || 'productivity';
const DEFAULT_HASHTAGS = config.app?.defaultHashtags || `#${APP_NAME} #IndieApp #AppStore`;
const DEFAULT_CTA = config.app?.defaultCta || `${APP_NAME} — available on the App Store`;
const TIMEZONE_OFFSET = config.schedule?.timezoneOffset || '-05:00'; // default EST

// Paths — all relative to the working directory (where config.json lives)
const BASE_DIR = path.dirname(CONFIG_PATH);
const PLANS_DIR = path.join(BASE_DIR, config.paths?.plans || 'plans');

// Content bank path — configurable
const CONTENT_BANK_PATH = path.join(
  BASE_DIR,
  config.paths?.contentBank || 'content-bank.json'
);
const FB_CONTENT_BANK_PATH = path.join(
  BASE_DIR,
  config.paths?.fbContentBank || 'fb-brand-content-bank.json'
);
const HISTORY_PATH = path.join(
  BASE_DIR,
  config.paths?.history || 'posting-history.json'
);
const TEMPLATES_PATH = config.paths?.templates
  ? path.join(BASE_DIR, config.paths.templates)
  : path.join(__dirname, '..', 'assets', 'content-templates.json');
const HASHTAG_BANK_PATH = path.join(BASE_DIR, 'hashtag-bank.json');

// ── Load data ────────────────────────────────────────────────────────────────
function loadJSON(filePath, fallback = null) {
  if (!fs.existsSync(filePath)) {
    if (fallback !== null) return fallback;
    console.error(`Required file not found: ${filePath}`);
    process.exit(1);
  }
  try {
    return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
  } catch (e) {
    console.error(`Failed to parse ${filePath}: ${e.message}`);
    process.exit(1);
  }
}

const contentBank  = loadJSON(CONTENT_BANK_PATH);
const history      = loadJSON(HISTORY_PATH, { posted: [], lastApp: null });
const templates    = loadJSON(TEMPLATES_PATH);
const fbBrandBank  = loadJSON(FB_CONTENT_BANK_PATH, { brand: [] });
const hashtagBank  = loadJSON(HASHTAG_BANK_PATH, { trending: [], evergreen: [] });

const postedIds   = new Set((history.posted || []).map(p => p.id));
const postedFbIds = new Set((history.postedFbBrand || []).map(p => p.id));

// Normalize content bank: support both flat array and keyed object
// If array: use as-is. If object: merge all arrays under each key.
let allAngles;
if (Array.isArray(contentBank)) {
  allAngles = contentBank;
} else {
  allAngles = Object.values(contentBank).flat();
}

// ── Posting schedule ─────────────────────────────────────────────────────────
// Override any of these in config.json under "schedule"
const ANGLES_PER_DAY = config.schedule?.anglesPerDay || 3;

const VISUAL_SCHEDULE = config.schedule?.visual || [
  { slot: 'tiktok-1',        time: '08:00', platform: 'tiktok',   videoIndex: 0 },
  { slot: 'youtube-short-1', time: '08:15', platform: 'youtube',  videoIndex: 0, reusesVideo: true, reusesSlot: 'tiktok-1' },
  { slot: 'tiktok-2',        time: '13:00', platform: 'tiktok',   videoIndex: 1 },
  { slot: 'youtube-short-2', time: '13:15', platform: 'youtube',  videoIndex: 1, reusesVideo: true, reusesSlot: 'tiktok-2' },
  { slot: 'tiktok-3',        time: '18:00', platform: 'tiktok',   videoIndex: 2 },
  { slot: 'youtube-short-3', time: '18:15', platform: 'youtube',  videoIndex: 2, reusesVideo: true, reusesSlot: 'tiktok-3' },
];

const X_SCHEDULE = config.schedule?.x || [
  { slot: 'x-1', time: '10:30', platform: 'x', tweetIndex: 0, linkType: 'appStore' },
  { slot: 'x-2', time: '16:00', platform: 'x', tweetIndex: 1, linkType: 'website'  },
];

const FB_SCHEDULE = config.schedule?.fb || [
  { slot: 'fb-text-1', time: '12:00', platform: 'facebook', type: 'brand-post' },
];

// Days of week that get a Facebook post (1=Mon, 3=Wed, 5=Fri)
const FB_DAYS = config.schedule?.fbDays || [1, 3, 5];

// ── Date helpers ─────────────────────────────────────────────────────────────
function getNextMonday() {
  const now = new Date();
  const day = now.getDay();
  const daysUntilMonday = day === 1 ? 7 : (1 - day + 7) % 7 || 7;
  const monday = new Date(now);
  monday.setDate(now.getDate() + daysUntilMonday);
  return monday.toISOString().slice(0, 10);
}

function addDays(dateStr, days) {
  const d = new Date(dateStr + 'T00:00:00');
  d.setDate(d.getDate() + days);
  return d.toISOString().slice(0, 10);
}

function dayName(dateStr) {
  return new Date(dateStr + 'T12:00:00').toLocaleDateString('en-US', { weekday: 'long' });
}

function toScheduledISO(dateStr, timeStr) {
  return `${dateStr}T${timeStr}:00.000${TIMEZONE_OFFSET}`;
}

// ── Content selection ────────────────────────────────────────────────────────
function getUnusedAngles() {
  return allAngles.filter(a => !postedIds.has(a.id));
}

function pickAnglesForWeek(numDays) {
  const unused = getUnusedAngles();
  console.log(`Available: ${unused.length} unused angles (${allAngles.length} total)`);

  if (unused.length === 0) {
    console.error('No unused angles! Add more to content-bank.json.');
    process.exit(1);
  }

  // Simple sequential pick — no alternating apps since single-app by default
  const needed = numDays * ANGLES_PER_DAY;
  return unused.slice(0, needed);
}

// ── Facebook brand post picker ────────────────────────────────────────────────
function pickFbBrandPosts(count) {
  const allFb = Array.isArray(fbBrandBank)
    ? fbBrandBank
    : Object.values(fbBrandBank).flat();

  const unused = allFb.filter(p => !postedFbIds.has(p.id));
  return unused.slice(0, count);
}

// ── X Tweet generator ─────────────────────────────────────────────────────────
function getHashtags() {
  const trending  = Array.isArray(hashtagBank.trending)  ? hashtagBank.trending  : [];
  const evergreen = Array.isArray(hashtagBank.evergreen) ? hashtagBank.evergreen : [];

  const pool = [...trending.slice(0, 2), ...evergreen.slice(0, 2)];
  if (pool.length > 0) {
    return pool.slice(0, 3).map(h => h.tag || h).join(' ');
  }
  return DEFAULT_HASHTAGS.split(' ').slice(0, 3).join(' ');
}

function generateXTweet(angle, tweetIndex) {
  const hashtags = getHashtags();
  const link = tweetIndex % 2 === 0 ? APP_STORE_URL : WEBSITE_URL;
  const hook  = angle.hook;
  const texts = angle.texts || [];

  // Rotate through natural-voice patterns (no marketing-speak)
  const patterns = [
    // Observation
    () => {
      const insight = texts.slice(1, 3).map(t => t.replace(/\n/g, ' ')).join(' ');
      return `${hook}.\n\n${insight}`;
    },
    // Dev perspective
    () => {
      const detail = texts[2]?.replace(/\n/g, ' ') || texts[1]?.replace(/\n/g, ' ') || '';
      return `Something I keep hearing from people: ${hook.toLowerCase()}.\n\n${detail}`;
    },
    // Quick thought
    () => `${hook}. That's it. That's the tweet.\n\n${hashtags}`,
    // Engagement question
    () => {
      const question = hook.toLowerCase().replace(/^i /, 'you ').replace(/^my /, 'your ');
      return `Be honest: ${question}?\n\nBecause same.`;
    },
    // Story snippet
    () => texts.slice(0, 2).map(t => t.replace(/\n/g, ' ')).join(' '),
  ];

  const patternFn = patterns[tweetIndex % patterns.length];
  let body = patternFn();

  // Include App Store link on even-indexed tweets
  if (tweetIndex % 2 === 0 && link) {
    body = `${body}\n\n${link}`;
  }

  // Append hashtags if not already in body
  if (!body.includes('#') && hashtags) {
    body = `${body}\n\n${hashtags}`;
  }

  return body;
}

// Trim to X 280-char limit while preserving the last line (link or hashtags)
function trimToXLimit(text) {
  if (text.length <= 280) return text;

  const lines    = text.split('\n');
  const lastLine = lines[lines.length - 1];
  const isAnchor = lastLine.startsWith('http') || lastLine.startsWith('#');

  if (isAnchor) {
    const bodyLines = lines.slice(0, -1);
    const bodyClean = bodyLines.join('\n').replace(/#\S+/g, '').trim();
    const candidate = `${bodyClean}\n\n${lastLine}`;
    if (candidate.length <= 280) return candidate;

    const sentences = bodyClean.split(/(?<=[.!?])\s+/);
    let result = '';
    for (const s of sentences) {
      const next = (result ? `${result} ${s}` : s).trim();
      if (`${next}\n\n${lastLine}`.length > 280) break;
      result = next;
    }
    if (result) return `${result}\n\n${lastLine}`;
    return `${bodyClean.substring(0, 277 - lastLine.length - 4)}...\n\n${lastLine}`;
  }

  return `${text.substring(0, 277)}...`;
}

// ── Build the weekly plan ─────────────────────────────────────────────────────
function buildWeeklyPlan(weekStart, numDays) {
  const angles = pickAnglesForWeek(numDays);

  const daysAvailable = Math.floor(angles.length / ANGLES_PER_DAY);
  const daysToPlan    = Math.min(daysAvailable, numDays);

  if (daysToPlan === 0) {
    console.error('Not enough angles to plan even one day.');
    process.exit(1);
  }

  // Collect dates that need a Facebook post
  const fbDayIndices = [];
  for (let i = 0; i < daysToPlan; i++) {
    const dow = new Date(addDays(weekStart, i) + 'T12:00:00').getDay();
    if (FB_DAYS.includes(dow)) fbDayIndices.push(i);
  }

  const fbBrandPosts = pickFbBrandPosts(fbDayIndices.length);
  console.log(`FB posts available: ${fbBrandPosts.length} (need ${fbDayIndices.length})`);
  console.log(`\nAngles available: ${angles.length} (${daysAvailable} full days)`);
  console.log(`Planning ${daysToPlan} posting days starting ${weekStart}\n`);

  const plan = {
    meta: {
      weekStart,
      weekEnd:    addDays(weekStart, daysToPlan - 1),
      createdAt:  new Date().toISOString(),
      app:        APP_NAME,
      platforms:  ['tiktok', 'youtube', 'x', 'facebook'],
      note:       'Generated by indie-app-marketing-pipeline weekly-planner — template-driven, no LLM',
    },
    days: [],
  };

  let fbPostIndex = 0;

  for (let dayIdx = 0; dayIdx < daysToPlan; dayIdx++) {
    const day      = addDays(weekStart, dayIdx);
    const dayLabel = dayName(day);
    const dow      = new Date(day + 'T12:00:00').getDay();

    const dayAngles = angles.slice(
      dayIdx * ANGLES_PER_DAY,
      dayIdx * ANGLES_PER_DAY + ANGLES_PER_DAY
    );

    console.log(`${dayLabel} ${day}:`);
    dayAngles.forEach((a, i) => console.log(`  Angle ${i + 1}: "${a.hook}"`));

    const dayPosts = [];

    // ── Visual posts (TikTok + YouTube) ──
    for (const slot of VISUAL_SCHEDULE) {
      const scheduledTime         = toScheduledISO(day, slot.time);
      const scheduledTimeReadable = `${dayLabel.slice(0, 3)} ${day.slice(5)}, ${slot.time}`;
      const angle                 = dayAngles[slot.videoIndex] || dayAngles[0];

      if (slot.platform === 'tiktok') {
        dayPosts.push({
          id:               `${slot.slot}-${day}`,
          slot:             slot.slot,
          day, dayLabel,
          angleId:          angle.id,
          hook:             angle.hook,
          caption:          angle.caption || '',
          platform:         'tiktok',
          type:             'video',
          scheduledTime,
          scheduledTimeReadable,
          requiresVideoGen: true,
        });
      } else if (slot.platform === 'youtube') {
        dayPosts.push({
          id:                  `${slot.slot}-${day}`,
          slot:                slot.slot,
          day, dayLabel,
          angleId:             angle.id,
          hook:                angle.hook,
          platform:            'youtube',
          type:                'short',
          reusesVideo:         true,
          reusesVideoFrom:     `${slot.reusesSlot}-${day}`,
          scheduledTime,
          scheduledTimeReadable,
          note:                `Reuse video from ${slot.reusesSlot}-${day}`,
        });
      }
    }

    // ── X tweets ──
    for (const slot of X_SCHEDULE) {
      const scheduledTime         = toScheduledISO(day, slot.time);
      const scheduledTimeReadable = `${dayLabel.slice(0, 3)} ${day.slice(5)}, ${slot.time}`;
      const angle = dayAngles[slot.tweetIndex % dayAngles.length] || dayAngles[0];
      const tweet = trimToXLimit(generateXTweet(angle, slot.tweetIndex));

      dayPosts.push({
        id:                  `${slot.slot}-${day}`,
        slot:                slot.slot,
        day, dayLabel,
        angleId:             angle.id,
        platform:            'x',
        type:                'tweet',
        content:             tweet,
        charCount:           tweet.length,
        scheduledTime,
        scheduledTimeReadable,
      });
    }

    // ── Facebook (Mon/Wed/Fri only) ──
    if (FB_DAYS.includes(dow)) {
      const fbPost = fbBrandPosts[fbPostIndex];
      if (fbPost) {
        const scheduledTime         = toScheduledISO(day, '12:00');
        const scheduledTimeReadable = `${dayLabel.slice(0, 3)} ${day.slice(5)}, 12:00`;
        dayPosts.push({
          id:                  `fb-text-1-${day}`,
          slot:                'fb-text-1',
          day, dayLabel,
          fbBrandId:           fbPost.id,
          platform:            'facebook',
          type:                'brand-post',
          content:             fbPost.content || '',
          scheduledTime,
          scheduledTimeReadable,
          note:                `FB brand post${fbPost.pattern ? `: ${fbPost.pattern}` : ''}`,
        });
        fbPostIndex++;
      }
    }

    plan.days.push({ day, dayLabel, angles: dayAngles.map(a => ({ id: a.id, hook: a.hook })), posts: dayPosts });
  }

  // Flatten for consumers that expect a top-level allPosts array
  plan.allPosts = plan.days.flatMap(d => d.posts);

  // Summary counts
  plan.meta.anglesUsed  = daysToPlan * ANGLES_PER_DAY;
  plan.meta.totalPosts  = plan.allPosts.length;
  plan.meta.byPlatform  = {
    tiktok:   plan.allPosts.filter(p => p.platform === 'tiktok').length,
    youtube:  plan.allPosts.filter(p => p.platform === 'youtube').length,
    x:        plan.allPosts.filter(p => p.platform === 'x').length,
    facebook: plan.allPosts.filter(p => p.platform === 'facebook').length,
  };

  return plan;
}

// ── Main ──────────────────────────────────────────────────────────────────────
const defaultDays = customDays || 7;
const weekStart   = getArg('week') || (customDays
  ? new Date().toISOString().slice(0, 10)
  : getNextMonday()
);

console.log('='.repeat(60));
console.log('WEEKLY CONTENT PLANNER — Indie App Marketing Pipeline');
console.log('='.repeat(60));
console.log(`App:   ${APP_NAME} (${APP_HANDLE})`);
console.log(`Start: ${weekStart} — ${addDays(weekStart, defaultDays - 1)} (${defaultDays} days)`);
console.log(`Mode:  ${dryRun ? 'DRY RUN' : 'LIVE'}\n`);

const plan = buildWeeklyPlan(weekStart, defaultDays);

// ── Summary ──
console.log('\n' + '='.repeat(60));
console.log('PLAN SUMMARY');
console.log('='.repeat(60));
console.log(`Posting days:     ${plan.days.length}`);
console.log(`Angles used:      ${plan.meta.anglesUsed}`);
console.log(`Total posts:      ${plan.meta.totalPosts}`);
console.log(`  TikTok:         ${plan.meta.byPlatform.tiktok}`);
console.log(`  YouTube Shorts: ${plan.meta.byPlatform.youtube} (reuse TikTok video)`);
console.log(`  X tweets:       ${plan.meta.byPlatform.x}`);
console.log(`  Facebook:       ${plan.meta.byPlatform.facebook}`);

if (dryRun) {
  console.log('\n[DRY RUN] Plan not written. Preview:');
  const sampleDay = plan.days[0];
  if (sampleDay) {
    console.log(`\nSample day: ${sampleDay.dayLabel} ${sampleDay.day}`);
    const sampleX = sampleDay.posts.find(p => p.platform === 'x');
    if (sampleX) {
      console.log(`\nSample X tweet (${sampleX.charCount} chars):\n${sampleX.content}`);
    }
  }
} else {
  fs.mkdirSync(PLANS_DIR, { recursive: true });
  const planPath = path.join(PLANS_DIR, `weekly-plan-${weekStart}.json`);
  fs.writeFileSync(planPath, JSON.stringify(plan, null, 2));
  console.log(`\nPlan written to: ${planPath}`);
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)