← back to Allnewsdaily
allnewsdaily: daily YouTube Short pipeline (headlines→vertical Short→unlisted upload)
9eb9cfafb7cb115712916e03d92778ff240c0154 · 2026-09-09 16:04:44 -0700 · Steve Abrams
Fans wire.json top-6 headlines through pick→script→ElevenLabs VO→ffmpeg/ImageMagick
render→resumable unlisted YouTube upload, on a daily launchd job (not yet loaded).
Includes Cody/DTD red-team hardening: feed-freshness guard (refuse stale news),
cross-day dedupe, fleet-visible PASS/WARN/FAIL canary, launchd-safe node/PATH,
resumable-resume upload retry, thumbnail upload, same-day lock, single-clock dates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .gitignoreA com.steve.allnewsdaily-daily-short.plistA scripts/short/CONTRACTS.mdA scripts/short/build-script.jsA scripts/short/make-daily-short.mjsA scripts/short/pick-stories.jsA scripts/short/render-short.jsA scripts/short/tts-elevenlabs.mjsA scripts/short/upload-youtube.mjsA scripts/short/youtube-auth.mjs
Diff
commit 9eb9cfafb7cb115712916e03d92778ff240c0154
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 9 16:04:44 2026 -0700
allnewsdaily: daily YouTube Short pipeline (headlines→vertical Short→unlisted upload)
Fans wire.json top-6 headlines through pick→script→ElevenLabs VO→ffmpeg/ImageMagick
render→resumable unlisted YouTube upload, on a daily launchd job (not yet loaded).
Includes Cody/DTD red-team hardening: feed-freshness guard (refuse stale news),
cross-day dedupe, fleet-visible PASS/WARN/FAIL canary, launchd-safe node/PATH,
resumable-resume upload retry, thumbnail upload, same-day lock, single-clock dates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.gitignore | 1 +
com.steve.allnewsdaily-daily-short.plist | 47 +++++
scripts/short/CONTRACTS.md | 82 +++++++++
scripts/short/build-script.js | 108 ++++++++++++
scripts/short/make-daily-short.mjs | 159 +++++++++++++++++
scripts/short/pick-stories.js | 159 +++++++++++++++++
scripts/short/render-short.js | 292 +++++++++++++++++++++++++++++++
scripts/short/tts-elevenlabs.mjs | 64 +++++++
scripts/short/upload-youtube.mjs | 234 +++++++++++++++++++++++++
scripts/short/youtube-auth.mjs | 264 ++++++++++++++++++++++++++++
10 files changed, 1410 insertions(+)
diff --git a/.gitignore b/.gitignore
index 9981df4..489c960 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ data/live-status.json
data/paraphrase-cache.json
data/wire.json
data/live-static.flag
+data/short/
diff --git a/com.steve.allnewsdaily-daily-short.plist b/com.steve.allnewsdaily-daily-short.plist
new file mode 100644
index 0000000..0bd363c
--- /dev/null
+++ b/com.steve.allnewsdaily-daily-short.plist
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<!-- allnewsdaily daily YouTube Short (TK-11342). Renders the top ~6 wire.json headlines into a
+ vertical <60s Short (ElevenLabs VO + branded cards) and uploads it UNLISTED for Steve to promote.
+ NOT loaded automatically — installing a recurring auto-publish + metered job is Steve-gated, and
+ it requires YOUTUBE_REFRESH_TOKEN (run youtube-auth.mjs first).
+ Load: launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.allnewsdaily-daily-short.plist
+ Unload: launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.steve.allnewsdaily-daily-short.plist -->
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>com.steve.allnewsdaily-daily-short</string>
+
+ <key>ProgramArguments</key>
+ <array>
+ <string>/opt/homebrew/bin/node</string>
+ <string>/Users/macstudio3/Projects/allnewsdaily/scripts/short/make-daily-short.mjs</string>
+ </array>
+
+ <!-- Cody #2: launchd strips PATH to /usr/bin:/bin — put Homebrew first so node/ffmpeg/ffprobe/magick resolve. -->
+ <key>EnvironmentVariables</key>
+ <dict>
+ <key>PATH</key>
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+ <key>HOME</key>
+ <string>/Users/macstudio3</string>
+ </dict>
+
+ <key>WorkingDirectory</key>
+ <string>/Users/macstudio3/Projects/allnewsdaily</string>
+
+ <!-- 6:00 AM local, daily. -->
+ <key>StartCalendarInterval</key>
+ <dict>
+ <key>Hour</key><integer>6</integer>
+ <key>Minute</key><integer>0</integer>
+ </dict>
+
+ <key>RunAtLoad</key>
+ <false/>
+
+ <key>StandardOutPath</key>
+ <string>/Users/macstudio3/Projects/allnewsdaily/data/short/daily-short.log</string>
+ <key>StandardErrorPath</key>
+ <string>/Users/macstudio3/Projects/allnewsdaily/data/short/daily-short.log</string>
+</dict>
+</plist>
diff --git a/scripts/short/CONTRACTS.md b/scripts/short/CONTRACTS.md
new file mode 100644
index 0000000..f2a1d34
--- /dev/null
+++ b/scripts/short/CONTRACTS.md
@@ -0,0 +1,82 @@
+# allnewsdaily daily-Short pipeline — shared interface contract (TK-11342)
+
+All stages live in `~/Projects/allnewsdaily/scripts/short/`. Generated media goes in
+`~/Projects/allnewsdaily/data/short/` (gitignored — do not commit media/artifacts).
+Node is v26 (built-in `fetch` available; **use raw fetch, add no heavy npm deps**).
+
+## INPUT — `~/Projects/allnewsdaily/data/wire.json` (already exists, refreshed every 2 min)
+```
+{
+ updatedAt: ISO,
+ splash: { outlet, link, date, topic }, // the LEAD story
+ columns: [ { key, title, items:[ {outlet,link,date,topic} ], ok, total } ] // 3 columns
+}
+```
+**CRITICAL, verified 2026-09-09:** there is **NO `title` field**. `item.topic` **IS the
+headline sentence** (a full sentence, e.g. "Google has announced a €13 billion investment…").
+`item.outlet` = source name. `item.link` = article URL. `item.date` may be RSS or ISO format.
+
+## STAGE 1 — `pick-stories.js` (owner: content subagent)
+Read wire.json → select **6** stories: `splash` first, then the strongest items across the 3
+columns. Dedupe by outlet (max 2 per outlet). Skip items whose `topic` is empty or < 25 chars.
+Write `data/short/stories.json`:
+```
+{ date:"YYYY-MM-DD", generatedAt:ISO,
+ stories:[ { n:1, headline:<item.topic, trimmed>, outlet, link, tag:<column title or "Top"> }, … 6 ] }
+```
+`node scripts/short/pick-stories.js` also prints the 6 chosen headlines.
+
+## STAGE 2 — `build-script.js` (owner: content subagent)
+Read stories.json → build a **45–55s** narration for a vertical Short. FACTUAL news-brief tone,
+**no opinion/editorializing**. Intro: "All News Daily. Your headlines for {Month D}." Each beat:
+"{headline} — via {outlet}." Outro: "That's your briefing. Full stories and live coverage at
+all news daily dot com." Pace ≈ 2.7 words/sec. Hard cap totalSec ≤ 58 (Shorts must be < 60s);
+if over, drop to 5 beats. Write `data/short/script.json`:
+```
+{ intro:{text,estSec}, beats:[{n,headline,outlet,text,estSec}], outro:{text,estSec},
+ totalSec, narration:"<full flat text, newline between segments>" }
+```
+
+## STAGE 3 — `render-short.js` (owner: render subagent — the ffmpeg heavy-lift)
+Read stories.json + script.json + `data/short/vo.mp3` (voiceover; if absent, render silent + WARN).
+Produce `data/short/out.mp4`: **1080×1920, H.264/yuv420p, 30fps, < 60s, AAC audio**.
+- Use **ffmpeg `drawtext`** (NOT node-canvas — avoid the native cairo build). Bundled font:
+ `/System/Library/Fonts/Supplemental/Arial Bold.ttf` (fallback Helvetica).
+- Per beat = a branded card: dark bg (#0a0a0a / subtle gradient), top wordmark **ALL NEWS DAILY**
+ with a red accent bar, the **headline word-wrapped & centered** (wrap ≈ 22–26 chars/line),
+ the **outlet** as a chip lower-third, and a subtle Ken-Burns zoompan.
+- Sync card durations to the REAL VO length: `ffprobe` vo.mp3 for duration, distribute across
+ intro+beats+outro proportional to each segment's estSec.
+- Also emit `data/short/thumb.jpg` (a strong first/lead-card frame, 1080×1920).
+- Robust: check ffmpeg exists; fail LOUD with a clear message. `node scripts/short/render-short.js`.
+
+## STAGE 4 — `youtube-auth.js` + `upload-youtube.js` (owner: upload/auth subagent)
+Creds from `~/Projects/secrets-manager/.env`: `YOUTUBE_CLIENT_ID`, `YOUTUBE_CLIENT_SECRET`.
+- **`youtube-auth.js`** — OAuth 2.0 **loopback** flow. Scope
+ `https://www.googleapis.com/auth/youtube.upload` + `.../youtube.readonly`. Start a localhost
+ server on `http://localhost:9964/oauth2callback`, PRINT the consent URL for Steve to open,
+ catch the `code`, exchange for tokens, and SAVE `refresh_token` to
+ `~/Projects/allnewsdaily/.env` as `YOUTUBE_REFRESH_TOKEN=` (that file is gitignored; note in
+ output that it should also be routed via the `secrets` skill). On success, call
+ `channels.list?mine=true&part=snippet` and PRINT the authorized channel title (confirms WHICH
+ channel). If token exchange returns `redirect_uri_mismatch`, print a clear instruction that
+ `http://localhost:9964/oauth2callback` must be added as an authorized redirect URI on the
+ OAuth client in Google Cloud Console. Raw fetch, no googleapis dep.
+- **`upload-youtube.js`** — export `async uploadShort({file,title,description,tags,privacyStatus='unlisted'})`.
+ Refresh an access token from `YOUTUBE_REFRESH_TOKEN`, then do a **resumable** upload: POST
+ `https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status`
+ with JSON metadata (snippet.title/description/tags/categoryId=**25** News&Politics;
+ status.privacyStatus; status.selfDeclaredMadeForKids=**false**), then PUT the file bytes to the
+ returned `Location`. Return `{videoId, url}`. CLI: `node scripts/short/upload-youtube.js --file
+ data/short/out.mp4 --title "…" --dry-run` (dry-run prints metadata, no upload; used for testing
+ before the OAuth token exists).
+
+## STAGE 5 — `make-daily-short.js` (orchestrator — owned by the merge step, do NOT build)
+Chains 1→2→ElevenLabs VO→3→4, logs cost, writes the daily canary `data/latest.json`
+(PASS/WARN/FAIL). The subagents do NOT build this; leave it to integration.
+
+## Rules for all subagents
+- Work under **TK-11342**; `tk log TK-11342 "…"` your actions. Do NOT `git commit` (merge step commits).
+- Self-test what you can WITHOUT spend or the OAuth token (dry-runs, silent-VO render, unit checks).
+- No editorializing in any narration/metadata — this is a news channel; facts + attribution only.
+- Do not fetch or embed the outlets' own images/video (copyright) — our branded cards only.
diff --git a/scripts/short/build-script.js b/scripts/short/build-script.js
new file mode 100644
index 0000000..6eebed6
--- /dev/null
+++ b/scripts/short/build-script.js
@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+/**
+ * STAGE 2 — build-script.js (TK-11342)
+ * Read data/short/stories.json -> build a 45–55s FACTUAL news-brief narration
+ * for a vertical Short (no opinion/editorializing). Write data/short/script.json
+ * per CONTRACTS.md. Hard cap totalSec <= 58; if over, drop to 5 beats.
+ *
+ * Pace ~ 2.7 words/sec.
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..', '..');
+const IN = path.join(ROOT, 'data', 'short', 'stories.json');
+const OUT_DIR = path.join(ROOT, 'data', 'short');
+const OUT = path.join(OUT_DIR, 'script.json');
+
+const WPS = 2.7; // words per second
+const HARD_CAP = 58; // seconds — Shorts must be < 60s
+
+function wordCount(s) {
+ return clean(s).split(/\s+/).filter(Boolean).length;
+}
+function clean(s) {
+ return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
+}
+function estSec(text) {
+ return Math.round((wordCount(text) / WPS) * 10) / 10; // 1 decimal
+}
+// "Month D" from a YYYY-MM-DD stamp (local-safe, no TZ shift).
+function monthDay(dateStr) {
+ let d;
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr || '')) {
+ const [y, m, day] = dateStr.split('-').map(Number);
+ d = new Date(y, m - 1, day);
+ } else {
+ d = new Date();
+ }
+ return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
+}
+// Ensure a headline sentence ends with terminal punctuation before "— via".
+function normHeadline(h) {
+ const t = clean(h);
+ return /[.!?]$/.test(t) ? t : t + '.';
+}
+
+function buildBeats(stories) {
+ return stories.map((s, i) => {
+ const text = `${normHeadline(s.headline)} — via ${clean(s.outlet)}.`;
+ return { n: i + 1, headline: clean(s.headline), outlet: clean(s.outlet), text, estSec: estSec(text) };
+ });
+}
+
+function assemble(intro, beats, outro) {
+ const total = Math.round((intro.estSec + beats.reduce((a, b) => a + b.estSec, 0) + outro.estSec) * 10) / 10;
+ const narration = [intro.text, ...beats.map((b) => b.text), outro.text].join('\n');
+ return { intro, beats, outro, totalSec: total, narration };
+}
+
+function main() {
+ if (!fs.existsSync(IN)) {
+ console.error(`[build-script] FATAL: stories.json not found at ${IN}. Run pick-stories.js first.`);
+ process.exit(1);
+ }
+ const data = JSON.parse(fs.readFileSync(IN, 'utf8'));
+ const stories = Array.isArray(data.stories) ? data.stories : [];
+ if (stories.length === 0) {
+ console.error('[build-script] FATAL: stories.json has no stories.');
+ process.exit(1);
+ }
+
+ const introText = `All News Daily. Your headlines for ${monthDay(data.date)}.`;
+ const outroText = `That's your briefing. Full stories and live coverage at all news daily dot com.`;
+ const intro = { text: introText, estSec: estSec(introText) };
+ const outro = { text: outroText, estSec: estSec(outroText) };
+
+ let beats = buildBeats(stories);
+ let script = assemble(intro, beats, outro);
+
+ // Hard cap: if over 58s, drop to 5 beats (then keep trimming as a safety net).
+ if (script.totalSec > HARD_CAP && beats.length > 5) {
+ beats = beats.slice(0, 5);
+ script = assemble(intro, beats, outro);
+ console.warn(`[build-script] over ${HARD_CAP}s cap — dropped to ${beats.length} beats.`);
+ }
+ while (script.totalSec > HARD_CAP && beats.length > 3) {
+ beats = beats.slice(0, beats.length - 1);
+ script = assemble(intro, beats, outro);
+ console.warn(`[build-script] still over cap — trimmed to ${beats.length} beats.`);
+ }
+
+ fs.mkdirSync(OUT_DIR, { recursive: true });
+ fs.writeFileSync(OUT, JSON.stringify(script, null, 2));
+
+ console.log(`[build-script] wrote script.json -> ${path.relative(ROOT, OUT)}`);
+ console.log(` beats=${beats.length} totalSec=${script.totalSec} (target 45–55, cap ${HARD_CAP})`);
+ if (script.totalSec < 45) console.warn('[build-script] WARN: narration under 45s target.');
+ if (script.totalSec > 55) console.warn('[build-script] WARN: narration over 55s target (still under hard cap).');
+
+ return script;
+}
+
+if (require.main === module) {
+ try { main(); }
+ catch (e) { console.error('[build-script] FATAL:', e && e.stack || e); process.exit(1); }
+}
+module.exports = { main };
diff --git a/scripts/short/make-daily-short.mjs b/scripts/short/make-daily-short.mjs
new file mode 100644
index 0000000..fef8797
--- /dev/null
+++ b/scripts/short/make-daily-short.mjs
@@ -0,0 +1,159 @@
+#!/usr/bin/env node
+// make-daily-short.mjs — ORCHESTRATOR (integration/merge node) for the allnewsdaily daily Short. TK-11342.
+// Chains: pick-stories → build-script → ElevenLabs VO → render-short → (unlisted) YouTube upload,
+// then writes a run record + a FLEET-VISIBLE canary (PASS/WARN/FAIL).
+//
+// Cody/DTD red-team hardening (2026-09-09):
+// - #2 launchd-safe: shells out with process.execPath (absolute node), not bare 'node'.
+// - #3 canary ALSO written to ~/.claude/skills/allnewsdaily-daily-short/data/latest.json
+// so fleet-health-rollup (globs skills/*/data/latest.json) actually sees a FAIL.
+// - #7 same-day lock + "already published today" guard (no double-post / no race).
+// - #8 single clock: title/description date comes from stories.json (pick time), not new Date().
+// - freshness: a stale feed (pick-stories exit 2) → WARN "refused stale", not a crash.
+// - history: aired links appended to data/short/history.json only after a real upload.
+//
+// Flags: --no-vo --no-upload --dry-run --force (ignore same-day guard) --allow-stale --privacy <v>
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+
+const DIR = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(DIR, '../..');
+const DATA = path.join(ROOT, 'data', 'short');
+const HISTORY = path.join(DATA, 'history.json');
+const LOCK = path.join(DATA, '.lock');
+const HOME = process.env.HOME || '/Users/macstudio3';
+const SKILL_DATA = path.join(HOME, '.claude', 'skills', 'allnewsdaily-daily-short', 'data');
+const LOCK_TTL_MS = 30 * 60 * 1000;
+
+const has = (f) => process.argv.includes(f);
+const argv = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
+const NO_VO = has('--no-vo'), NO_UPLOAD = has('--no-upload'), DRY = has('--dry-run');
+const FORCE = has('--force'), ALLOW_STALE = has('--allow-stale');
+const PRIVACY = argv('--privacy', 'unlisted');
+const todayStamp = () => new Date().toISOString().slice(0, 10);
+
+function readEnv(file, key) {
+ try { const l = fs.readFileSync(file, 'utf8').split('\n').find((x) => x.startsWith(key + '=')); return l ? l.slice(key.length + 1).trim() : null; }
+ catch { return null; }
+}
+// #2 — absolute node binary; launchd's stripped PATH has no 'node'.
+function node(script) {
+ const args = [path.join(DIR, script)];
+ if (ALLOW_STALE && script === 'pick-stories.js') args.push('--allow-stale');
+ execFileSync(process.execPath, args, { stdio: 'inherit', cwd: ROOT });
+}
+
+// #3 — write the run record locally AND a fleet-visible canary latest.json.
+function writeStatus(status, detail, extra = {}) {
+ const out = { skill: 'allnewsdaily-daily-short', ts: new Date().toISOString(), status, verdict: status, detail, ...extra };
+ fs.mkdirSync(DATA, { recursive: true });
+ fs.writeFileSync(path.join(DATA, 'latest-run.json'), JSON.stringify(out, null, 2));
+ try { fs.mkdirSync(SKILL_DATA, { recursive: true }); fs.writeFileSync(path.join(SKILL_DATA, 'latest.json'), JSON.stringify(out, null, 2)); }
+ catch (e) { console.error('[canary] could not write fleet latest.json:', e.message); }
+ const icon = status === 'PASS' ? '✓' : status === 'WARN' ? '⚠' : '✗';
+ console.log(`\n${icon} daily-short: ${status} — ${detail}`);
+ return out;
+}
+
+// #8 — one clock: the briefing date is fixed at pick time (stories.json.date).
+function buildMetadata(stories, dateStr) {
+ const d = new Date((dateStr || todayStamp()) + 'T12:00:00Z');
+ const monthDay = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' });
+ const lead = (stories[0]?.headline || 'Today’s headlines').replace(/\s+/g, ' ').trim();
+ const leadShort = lead.length > 60 ? lead.slice(0, 57).trimEnd() + '…' : lead;
+ const title = `All News Daily — ${monthDay}: ${leadShort}`.slice(0, 98);
+ const lines = stories.map((s, i) => `${i + 1}. ${s.headline} — ${s.outlet}\n ${s.link}`);
+ const outlets = [...new Set(stories.map((s) => s.outlet))];
+ const description = [
+ `Your ${monthDay} headlines from All News Daily.`, '',
+ ...lines, '',
+ 'Full stories + live coverage: https://allnewsdaily.com', '',
+ `Sources: ${outlets.join(', ')}`,
+ 'Headlines are summarized with source attribution; all reporting belongs to the outlets linked above.', '',
+ '#Shorts #news #headlines #dailynews #worldnews #currentevents',
+ ].join('\n');
+ const tags = ['news', 'headlines', 'daily news', 'world news', 'breaking news', 'current events', 'allnewsdaily', 'news brief', ...outlets].slice(0, 30);
+ return { title, description, tags, privacyStatus: PRIVACY };
+}
+
+function appendHistory(dateStr, links) {
+ let h = [];
+ try { h = JSON.parse(fs.readFileSync(HISTORY, 'utf8')); if (!Array.isArray(h)) h = []; } catch {}
+ h.push({ date: dateStr, ts: new Date().toISOString(), links });
+ fs.writeFileSync(HISTORY, JSON.stringify(h.slice(-14), null, 2)); // keep ~2 weeks
+}
+
+// #7 — lock so a manual test can't collide with the cron.
+function acquireLock() {
+ try {
+ const st = fs.statSync(LOCK);
+ if (Date.now() - st.mtimeMs < LOCK_TTL_MS) return false; // fresh lock held
+ } catch {}
+ fs.mkdirSync(DATA, { recursive: true });
+ fs.writeFileSync(LOCK, JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }));
+ return true;
+}
+const releaseLock = () => { try { fs.rmSync(LOCK); } catch {} };
+
+(async () => {
+ if (!acquireLock()) { console.error('[daily-short] another run holds the lock (< 30min old) — aborting.'); process.exit(0); }
+ try {
+ // #7 — already published today?
+ if (!FORCE && !NO_UPLOAD && !DRY) {
+ try {
+ const prev = JSON.parse(fs.readFileSync(path.join(DATA, 'latest-run.json'), 'utf8'));
+ if (prev.airedDate === todayStamp() && prev.videoId) { releaseLock(); return void writeStatus('PASS', `already published today (${prev.url}) — skipping (use --force to re-run)`, prev); }
+ } catch {}
+ }
+
+ console.log('▸ 1/5 pick-stories');
+ try { node('pick-stories.js'); }
+ catch (e) {
+ if (e.status === 2) { releaseLock(); return void writeStatus('WARN', 'feed stale/unverifiable — refused to publish stale news (wire-refresh loop likely dead). Use --allow-stale to override.', { costUSD: 0 }); }
+ throw e;
+ }
+ console.log('▸ 2/5 build-script'); node('build-script.js');
+
+ const storiesDoc = JSON.parse(fs.readFileSync(path.join(DATA, 'stories.json'), 'utf8'));
+ const stories = storiesDoc.stories;
+ const script = JSON.parse(fs.readFileSync(path.join(DATA, 'script.json'), 'utf8'));
+ if (!stories.length) { releaseLock(); return void writeStatus('FAIL', 'no eligible stories selected — nothing to render', { costUSD: 0 }); }
+
+ let cost = 0, voNote = 'silent (--no-vo)';
+ if (!NO_VO) {
+ console.log('▸ 3/5 ElevenLabs voiceover');
+ const { synthesize } = await import('./tts-elevenlabs.mjs');
+ const r = await synthesize({ text: script.narration, out: path.join(DATA, 'vo.mp3') });
+ cost = r.costUSD; voNote = `${r.chars} chars, ~$${r.costUSD} (${r.voice}/${r.model})`;
+ console.log(` VO: ${voNote}`);
+ } else { try { fs.rmSync(path.join(DATA, 'vo.mp3')); } catch {} }
+
+ console.log('▸ 4/5 render'); node('render-short.js');
+ const mp4 = path.join(DATA, 'out.mp4');
+ const thumb = path.join(DATA, 'thumb.jpg');
+ if (!fs.existsSync(mp4)) { releaseLock(); return void writeStatus('FAIL', 'render produced no out.mp4', { costUSD: cost }); }
+
+ const meta = buildMetadata(stories, storiesDoc.date);
+ console.log('▸ 5/5 upload');
+ if (NO_UPLOAD) { releaseLock(); return void writeStatus('PASS', `rendered ${mp4} — upload skipped (--no-upload)`, { costUSD: cost, voNote, title: meta.title }); }
+
+ const token = process.env.YOUTUBE_REFRESH_TOKEN || readEnv(path.join(ROOT, '.env'), 'YOUTUBE_REFRESH_TOKEN');
+ if (!token && !DRY) { releaseLock(); return void writeStatus('WARN', 'rendered OK but NO YOUTUBE_REFRESH_TOKEN — run `node scripts/short/youtube-auth.mjs` (one-time login) then re-run', { costUSD: cost, voNote, title: meta.title }); }
+
+ const { uploadShort } = await import('./upload-youtube.mjs');
+ if (DRY) { console.log('DRY-RUN upload metadata:\n' + JSON.stringify(meta, null, 2)); releaseLock(); return void writeStatus('PASS', 'dry-run — rendered + metadata built, no upload', { costUSD: cost, voNote, title: meta.title }); }
+
+ const res = await uploadShort({ file: mp4, thumbnail: fs.existsSync(thumb) ? thumb : undefined, ...meta });
+ appendHistory(storiesDoc.date, stories.map((s) => s.link).filter(Boolean)); // record only after a real upload
+ releaseLock();
+ return void writeStatus('PASS', `uploaded ${PRIVACY}: ${res.url} (thumbnail: ${res.thumbnail.note})`, {
+ costUSD: cost, voNote, title: meta.title, videoId: res.videoId, url: res.url, airedDate: storiesDoc.date,
+ });
+ } catch (e) {
+ releaseLock();
+ writeStatus('FAIL', 'pipeline error: ' + e.message);
+ process.exit(1);
+ }
+})();
diff --git a/scripts/short/pick-stories.js b/scripts/short/pick-stories.js
new file mode 100644
index 0000000..299fff0
--- /dev/null
+++ b/scripts/short/pick-stories.js
@@ -0,0 +1,159 @@
+#!/usr/bin/env node
+/**
+ * STAGE 1 — pick-stories.js (TK-11342)
+ * Read data/wire.json -> select 6 stories (splash first, then strongest column
+ * items across the 3 columns), dedupe by outlet (max 2/outlet), skip topics
+ * that are empty or < 25 chars. Write data/short/stories.json per CONTRACTS.md
+ * and print the 6 chosen headlines.
+ *
+ * NOTE (verified 2026-09-09): wire.json items have NO `title` field —
+ * `item.topic` IS the headline sentence.
+ *
+ * Cody/DTD red-team hardening (2026-09-09):
+ * - FRESHNESS GUARD (#1): refuse to run if wire.updatedAt is > MAX_STALE_MIN old,
+ * so a dead feed-refresh loop can never render stale news as "today's briefing"
+ * (the silent-wrong-success failure). Exit code 2. Override with --allow-stale.
+ * - CROSS-DAY DEDUPE (#1): exclude links aired in the last HISTORY_DAYS days
+ * (data/short/history.json, written by the orchestrator after a successful
+ * upload). Relaxes to refill if a slow-news day leaves < TARGET eligible.
+ * - DEFENSIVE ENTITY DECODE (#10b): decode HTML entities in headlines so a feed
+ * regression can't put "Apple &amp; Google" on a public card.
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..', '..');
+const WIRE = path.join(ROOT, 'data', 'wire.json');
+const OUT_DIR = path.join(ROOT, 'data', 'short');
+const OUT = path.join(OUT_DIR, 'stories.json');
+const HISTORY = path.join(OUT_DIR, 'history.json');
+
+const MIN_TOPIC_LEN = 25;
+const MAX_PER_OUTLET = 2;
+const TARGET = 6;
+const MAX_STALE_MIN = Number(process.env.AND_MAX_STALE_MIN || 30); // reject feeds older than this
+const HISTORY_DAYS = Number(process.env.AND_HISTORY_DAYS || 2); // don't re-air links aired in last N days
+const ALLOW_STALE = process.argv.includes('--allow-stale');
+
+const ENT = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'" };
+function decodeEntities(s) {
+ return s
+ .replace(/&(amp|lt|gt|quot|#39|apos);/g, (m) => ENT[m])
+ .replace(/&#(\d+);/g, (_, d) => { try { return String.fromCodePoint(+d); } catch { return _; } })
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return _; } });
+}
+function clean(s) {
+ return decodeEntities(s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
+}
+
+function dateStamp(iso) {
+ const d = iso ? new Date(iso) : new Date();
+ const use = isNaN(d.getTime()) ? new Date() : d;
+ return use.toISOString().slice(0, 10); // YYYY-MM-DD
+}
+
+// Links aired in the last HISTORY_DAYS days (the orchestrator appends after a real upload).
+function loadRecentLinks() {
+ try {
+ const h = JSON.parse(fs.readFileSync(HISTORY, 'utf8'));
+ const cutoff = new Date(Date.now() - HISTORY_DAYS * 86400000).toISOString().slice(0, 10);
+ const set = new Set();
+ (Array.isArray(h) ? h : []).forEach((d) => {
+ if (d && d.date >= cutoff) (d.links || []).forEach((l) => set.add(l));
+ });
+ return set;
+ } catch { return new Set(); }
+}
+
+function main() {
+ if (!fs.existsSync(WIRE)) {
+ console.error(`[pick-stories] FATAL: wire.json not found at ${WIRE}`);
+ process.exit(1);
+ }
+ const wire = JSON.parse(fs.readFileSync(WIRE, 'utf8'));
+
+ // --- FRESHNESS GUARD (#1) ---------------------------------------------------
+ const upd = Date.parse(wire.updatedAt);
+ if (!ALLOW_STALE) {
+ if (!Number.isFinite(upd)) {
+ console.error(`[pick-stories] FATAL: wire.json has no parseable updatedAt — cannot confirm freshness. Refusing to publish. (override: --allow-stale)`);
+ process.exit(2);
+ }
+ const ageMin = (Date.now() - upd) / 60000;
+ if (ageMin > MAX_STALE_MIN) {
+ console.error(`[pick-stories] FATAL: wire.json is ${ageMin.toFixed(0)}min stale (> ${MAX_STALE_MIN}min) — the feed-refresh loop is likely dead. Refusing to render stale news as today's briefing. (override: --allow-stale)`);
+ process.exit(2);
+ }
+ }
+
+ const recentLinks = loadRecentLinks();
+ const splash = wire.splash || null;
+ const columns = Array.isArray(wire.columns) ? wire.columns : [];
+
+ let allowRepeats = false; // flipped to refill if strict pass can't reach TARGET
+ const picked = [];
+ const outletCount = Object.create(null);
+ const seenLinks = new Set();
+
+ const outletKey = (o) => clean(o).toLowerCase();
+ function eligible(item) {
+ if (!item) return false;
+ const topic = clean(item.topic);
+ if (topic.length < MIN_TOPIC_LEN) return false; // skip short/empty
+ const link = clean(item.link);
+ if (link && seenLinks.has(link)) return false; // dedupe within this run
+ if (!allowRepeats && link && recentLinks.has(link)) return false; // cross-day dedupe (#1)
+ const ok = outletKey(item.outlet);
+ if (ok && (outletCount[ok] || 0) >= MAX_PER_OUTLET) return false; // <=2/outlet
+ return true;
+ }
+ function take(item, tag) {
+ const ok = outletKey(item.outlet);
+ if (ok) outletCount[ok] = (outletCount[ok] || 0) + 1;
+ const link = clean(item.link);
+ if (link) seenLinks.add(link);
+ picked.push({ n: picked.length + 1, headline: clean(item.topic), outlet: clean(item.outlet), link, tag: clean(tag) || 'Top' });
+ }
+
+ function selectPass() {
+ if (splash && eligible(splash)) take(splash, 'Top');
+ const cursors = columns.map(() => 0);
+ let progressed = true;
+ while (picked.length < TARGET && progressed) {
+ progressed = false;
+ for (let c = 0; c < columns.length && picked.length < TARGET; c++) {
+ const items = Array.isArray(columns[c].items) ? columns[c].items : [];
+ while (cursors[c] < items.length) {
+ const item = items[cursors[c]++];
+ progressed = true;
+ if (eligible(item)) { take(item, columns[c].title); break; }
+ }
+ }
+ }
+ }
+
+ selectPass();
+ // Refill: if cross-day dedupe left us short on a slow-news day, allow repeats.
+ if (picked.length < TARGET && recentLinks.size) {
+ console.warn(`[pick-stories] WARN: only ${picked.length}/${TARGET} fresh (non-repeat) stories — relaxing cross-day dedupe to refill.`);
+ allowRepeats = true;
+ selectPass();
+ }
+
+ const outDate = dateStamp(wire.updatedAt);
+ const result = { date: outDate, generatedAt: new Date().toISOString(), stories: picked };
+ fs.mkdirSync(OUT_DIR, { recursive: true });
+ fs.writeFileSync(OUT, JSON.stringify(result, null, 2));
+
+ console.log(`[pick-stories] wrote ${picked.length} stories -> ${path.relative(ROOT, OUT)} (date ${outDate}, feed ${Number.isFinite(upd) ? ((Date.now() - upd) / 60000).toFixed(0) + 'min old' : 'age?'})`);
+ if (picked.length < TARGET) console.warn(`[pick-stories] WARN: only ${picked.length}/${TARGET} eligible stories found`);
+ picked.forEach((s) => console.log(` ${s.n}. ${s.headline} — via ${s.outlet} [${s.tag}]`));
+ return result;
+}
+
+if (require.main === module) {
+ try { main(); }
+ catch (e) { console.error('[pick-stories] FATAL:', (e && e.stack) || e); process.exit(1); }
+}
+module.exports = { main };
diff --git a/scripts/short/render-short.js b/scripts/short/render-short.js
new file mode 100644
index 0000000..e1edab4
--- /dev/null
+++ b/scripts/short/render-short.js
@@ -0,0 +1,292 @@
+#!/usr/bin/env node
+/**
+ * STAGE 3 — render-short.js (TK-11342) owner: render subagent (ffmpeg heavy-lift)
+ *
+ * Reads: data/short/stories.json + data/short/script.json + data/short/vo.mp3 (optional)
+ * Writes: data/short/out.mp4 (1080x1920, H.264/yuv420p, 30fps, <60s, AAC audio)
+ * data/short/thumb.jpg (1080x1920, strong lead-card frame)
+ *
+ * If vo.mp3 is ABSENT we render a SILENT video (silent AAC track) and print a WARN.
+ * Every card is a branded ALL NEWS DAILY vertical card built purely with ffmpeg drawtext
+ * (no node-canvas / cairo). We do NOT embed any outlet imagery — branded cards only.
+ */
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+// ---------------------------------------------------------------------------
+// Paths
+// ---------------------------------------------------------------------------
+const ROOT = path.resolve(__dirname, '..', '..'); // ~/Projects/allnewsdaily
+const DATA = path.join(ROOT, 'data', 'short');
+const TMP = path.join(DATA, '.rtmp');
+
+const STORIES_PATH = path.join(DATA, 'stories.json');
+const SCRIPT_PATH = path.join(DATA, 'script.json');
+const VO_PATH = path.join(DATA, 'vo.mp3');
+const OUT_PATH = path.join(DATA, 'out.mp4');
+const THUMB_PATH = path.join(DATA, 'thumb.jpg');
+
+// ---------------------------------------------------------------------------
+// Canvas / brand constants
+// ---------------------------------------------------------------------------
+const W = 1080, H = 1920, FPS = 30;
+const HARD_CAP_SEC = 58; // Shorts must be < 60s; keep a safety margin
+const RED = '0xE10600';
+const INK = '0xF6F6F6';
+const SUB = '0xB8B8B8';
+const WRAP_CHARS = 24; // ~22–26 chars/line target
+const FONT_PRIMARY = '/System/Library/Fonts/Supplemental/Arial Bold.ttf';
+const FONT_FALLBACK = '/System/Library/Fonts/Helvetica.ttc';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+function die(msg) {
+ console.error('\n[render-short] FATAL: ' + msg + '\n');
+ process.exit(1);
+}
+
+function which(bin) {
+ // Honor env override, then homebrew, then PATH.
+ const envKey = bin.toUpperCase();
+ if (process.env[envKey] && fs.existsSync(process.env[envKey])) return process.env[envKey];
+ const brew = '/opt/homebrew/bin/' + bin;
+ if (fs.existsSync(brew)) return brew;
+ try {
+ const p = execFileSync('/usr/bin/which', [bin], { encoding: 'utf8' }).trim();
+ if (p) return p;
+ } catch (_) { /* fall through */ }
+ return null;
+}
+
+function run(bin, args) {
+ return execFileSync(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
+}
+
+function readJSON(p, label) {
+ if (!fs.existsSync(p)) die(`missing required input ${label} at ${p}`);
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
+ catch (e) { die(`could not parse ${label} (${p}): ${e.message}`); }
+}
+
+// ---------------------------------------------------------------------------
+// Boot: verify tools, inputs, tmp dir, font
+// ---------------------------------------------------------------------------
+const FFMPEG = which('ffmpeg');
+const FFPROBE = which('ffprobe');
+if (!FFMPEG) die('ffmpeg not found (checked $FFMPEG, /opt/homebrew/bin, PATH). Install: brew install ffmpeg');
+if (!FFPROBE) die('ffprobe not found (checked $FFPROBE, /opt/homebrew/bin, PATH). Install: brew install ffmpeg');
+
+// Text is composited with ImageMagick (freetype), NOT node-canvas/cairo. We use this
+// because THIS ffmpeg build was compiled without --enable-libfreetype, so the `drawtext`
+// filter is unavailable (verified: `ffmpeg -filters` has no drawtext). ffmpeg still does
+// all the video work (Ken-Burns zoompan, concat, AAC mux); IM only renders the card PNGs.
+const MAGICK = which('magick') || which('convert');
+const HAS_DRAWTEXT = (() => {
+ try { return /(^|\s)drawtext(\s|$)/m.test(run(FFMPEG, ['-hide_banner', '-filters'])); }
+ catch (_) { return false; }
+})();
+if (!MAGICK && !HAS_DRAWTEXT) {
+ die('no text renderer available: this ffmpeg lacks the drawtext filter AND ImageMagick ' +
+ '(`magick`/`convert`) is not installed. Install one: brew install imagemagick (or an ' +
+ 'ffmpeg built --enable-libfreetype).');
+}
+if (!HAS_DRAWTEXT) console.warn('[render-short] NOTE: ffmpeg has no drawtext filter — compositing card text with ImageMagick.');
+
+const stories = readJSON(STORIES_PATH, 'stories.json');
+const script = readJSON(SCRIPT_PATH, 'script.json');
+if (!script.beats || !Array.isArray(script.beats) || !script.beats.length) die('script.json has no beats[]');
+if (!script.intro || !script.outro) die('script.json missing intro/outro');
+
+fs.rmSync(TMP, { recursive: true, force: true });
+fs.mkdirSync(TMP, { recursive: true });
+
+// Resolve the branded font (Arial Bold, Helvetica fallback). Passed as a plain CLI arg to
+// ImageMagick, so the space in the path is fine (no filtergraph escaping needed).
+const FONT = fs.existsSync(FONT_PRIMARY) ? FONT_PRIMARY
+ : (fs.existsSync(FONT_FALLBACK) ? FONT_FALLBACK : null);
+if (!FONT) die(`no usable font (looked for "${FONT_PRIMARY}" then "${FONT_FALLBACK}")`);
+if (FONT === FONT_FALLBACK) console.warn('[render-short] WARN: Arial Bold not found — using Helvetica fallback.');
+
+// ---------------------------------------------------------------------------
+// VO detection + duration → target total
+// ---------------------------------------------------------------------------
+let voDur = null, silent = true;
+if (fs.existsSync(VO_PATH) && fs.statSync(VO_PATH).size > 0) {
+ try {
+ const out = run(FFPROBE, ['-v', 'error', '-show_entries', 'format=duration',
+ '-of', 'default=noprint_wrappers=1:nokey=1', VO_PATH]).trim();
+ const d = parseFloat(out);
+ if (isFinite(d) && d > 0) { voDur = d; silent = false; }
+ } catch (e) { console.warn('[render-short] WARN: ffprobe on vo.mp3 failed: ' + e.message); }
+}
+
+const scriptTotal = Number(script.totalSec) || null;
+let target;
+if (!silent) {
+ target = voDur;
+ console.log(`[render-short] VO found: vo.mp3 = ${voDur.toFixed(2)}s — cards will sync to real audio.`);
+} else {
+ target = scriptTotal || script.intro.estSec + script.outro.estSec +
+ script.beats.reduce((s, b) => s + (Number(b.estSec) || 0), 0);
+ console.warn(`[render-short] WARN: no vo.mp3 — rendering SILENT. Using script timing (~${target.toFixed(1)}s). ` +
+ `(TTS is skipped to avoid spend; STAGE 5 orchestrator supplies real VO.)`);
+}
+if (target > HARD_CAP_SEC) {
+ console.warn(`[render-short] WARN: target ${target.toFixed(1)}s > ${HARD_CAP_SEC}s cap — compressing card durations to stay < 60s.`);
+ target = HARD_CAP_SEC;
+}
+
+// ---------------------------------------------------------------------------
+// Build the segment list (intro + beats + outro) and distribute durations
+// proportional to each segment's estSec, scaled so the sum == target.
+// ---------------------------------------------------------------------------
+function cleanIntro(t) { return String(t).replace(/^\s*All News Daily\.\s*/i, '').trim() || t; }
+
+const segments = [];
+segments.push({ kind: 'intro', main: cleanIntro(script.intro.text), outlet: null,
+ est: Number(script.intro.estSec) || 3, mainSize: 62, tag: 'BRIEFING' });
+for (const b of script.beats) {
+ segments.push({ kind: 'beat', main: b.headline || b.text, outlet: b.outlet || null,
+ est: Number(b.estSec) || 5, mainSize: 74, n: b.n });
+}
+segments.push({ kind: 'outro', main: script.outro.text, outlet: null,
+ est: Number(script.outro.estSec) || 5, mainSize: 60, tag: 'ALLNEWSDAILY.COM' });
+
+const estSum = segments.reduce((s, x) => s + x.est, 0);
+let durs = segments.map(x => Math.max(1.4, +(target * x.est / estSum).toFixed(3)));
+// Rescale after the per-card minimum clamp so the sum lands on target and stays < cap.
+let dsum = durs.reduce((a, b) => a + b, 0);
+if (dsum > HARD_CAP_SEC || Math.abs(dsum - target) > 0.05) {
+ const scale = Math.min(target, HARD_CAP_SEC) / dsum;
+ durs = durs.map(d => +(d * scale).toFixed(3));
+ dsum = durs.reduce((a, b) => a + b, 0);
+}
+console.log(`[render-short] ${segments.length} cards, total ${dsum.toFixed(2)}s (target ${target.toFixed(2)}s).`);
+
+// ---------------------------------------------------------------------------
+// Build one branded card as a supersampled PNG (1350x2400 → crisp when zoompan'd
+// down into 1080x1920). Text is composited with ImageMagick; @file reads sidestep
+// all shell/escaping issues for €, $, ', — etc. Headline uses caption: with a fixed
+// box so ImageMagick auto-fits the point size (implements the word-wrap requirement
+// by wrapping to width, no matter the headline length).
+// ---------------------------------------------------------------------------
+const CARD_W = 1350, CARD_H = 2400;
+const HEX_BG0 = '#0A0A0A', HEX_BG1 = '#17171B', HEX_INK = '#F6F6F6', HEX_RED = '#E10600';
+
+function writeRaw(name, str) { const p = path.join(TMP, name); fs.writeFileSync(p, String(str)); return p; }
+
+function buildCardPng(seg, idx) {
+ const png = path.join(TMP, `card${idx}.png`);
+
+ // 1) dark vertical gradient base
+ run(MAGICK, ['-size', `${CARD_W}x${CARD_H}`, `gradient:${HEX_BG0}-${HEX_BG1}`, png]);
+
+ // 2) tracked wordmark + red accent bar
+ run(MAGICK, [png, '-font', FONT, '-gravity', 'North',
+ '-fill', HEX_INK, '-pointsize', '56', '-annotate', '+0+215', 'A L L N E W S D A I L Y',
+ '-fill', HEX_RED, '-draw', 'rectangle 540,352 810,366', png]);
+
+ // 3) headline / main text — auto-fit inside a centered box (the word-wrap)
+ const hlFile = writeRaw(`hl${idx}.txt`, seg.main);
+ run(MAGICK, [png, '(', '-background', 'none', '-fill', HEX_INK, '-font', FONT,
+ '-size', '1160x1040', '-gravity', 'center', `caption:@${hlFile}`, ')',
+ '-gravity', 'center', '-geometry', '+0+0', '-composite', png]);
+
+ // 4) lower-third chip — outlet (beats) or a brand tag (intro/outro)
+ const chipText = seg.outlet ? ('VIA ' + String(seg.outlet).toUpperCase()) : (seg.tag || null);
+ if (chipText) {
+ const chFile = writeRaw(`chip${idx}.txt`, chipText);
+ run(MAGICK, [png, '(', '-background', HEX_RED, '-fill', 'white', '-font', FONT,
+ '-pointsize', '46', `label:@${chFile}`, '-bordercolor', HEX_RED, '-border', '28x18', ')',
+ '-gravity', 'North', '-geometry', '+0+1900', '-composite', png]);
+ }
+ return png;
+}
+
+// Render one card PNG → seg mp4 with a subtle Ken-Burns push-in (zoompan 1.0→~1.10).
+function renderCard(seg, dur, idx) {
+ const png = buildCardPng(seg, idx);
+ const segMp4 = path.join(TMP, `seg${idx}.mp4`);
+ const vf = "zoompan=z='min(1.0+0.00055*on,1.10)':d=1:" +
+ "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':" + `s=${W}x${H}:fps=${FPS}`;
+ run(FFMPEG, ['-y', '-loop', '1', '-framerate', String(FPS), '-t', dur.toFixed(3), '-i', png,
+ '-vf', vf, '-r', String(FPS),
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-an', segMp4]);
+ return segMp4;
+}
+
+console.log('[render-short] rendering cards…');
+const segFiles = segments.map((seg, i) => {
+ const f = renderCard(seg, durs[i], i);
+ process.stdout.write(` card ${i + 1}/${segments.length} (${seg.kind}) ${durs[i].toFixed(2)}s\n`);
+ return f;
+});
+
+// ---------------------------------------------------------------------------
+// Concat all cards (identical params → stream copy)
+// ---------------------------------------------------------------------------
+const concatList = path.join(TMP, 'concat.txt');
+fs.writeFileSync(concatList, segFiles.map(f => `file '${f}'`).join('\n'));
+const VIDEO = path.join(TMP, 'video.mp4');
+run(FFMPEG, ['-y', '-f', 'concat', '-safe', '0', '-i', concatList, '-c', 'copy', VIDEO]);
+
+// ---------------------------------------------------------------------------
+// Mux audio → out.mp4 (real VO, or a silent AAC track if none)
+// ---------------------------------------------------------------------------
+console.log('[render-short] muxing audio → out.mp4…');
+if (!silent) {
+ run(FFMPEG, ['-y', '-i', VIDEO, '-i', VO_PATH,
+ '-map', '0:v:0', '-map', '1:a:0',
+ '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k',
+ '-shortest', '-movflags', '+faststart', OUT_PATH]);
+} else {
+ run(FFMPEG, ['-y', '-i', VIDEO,
+ '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
+ '-map', '0:v:0', '-map', '1:a:0',
+ '-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
+ '-shortest', '-movflags', '+faststart', OUT_PATH]);
+}
+
+// ---------------------------------------------------------------------------
+// Thumbnail: a strong frame from the LEAD card (first beat = splash story)
+// ---------------------------------------------------------------------------
+const leadIdx = segments.findIndex(s => s.kind === 'beat');
+const leadSeg = segFiles[leadIdx >= 0 ? leadIdx : 0];
+const seekTo = Math.min(1.0, Math.max(0.2, (durs[leadIdx >= 0 ? leadIdx : 0]) * 0.5));
+run(FFMPEG, ['-y', '-ss', seekTo.toFixed(2), '-i', leadSeg,
+ '-frames:v', '1', '-q:v', '2', THUMB_PATH]);
+
+// ---------------------------------------------------------------------------
+// Verify & report
+// ---------------------------------------------------------------------------
+function probe(p) {
+ const out = run(FFPROBE, ['-v', 'error',
+ '-select_streams', 'v:0',
+ '-show_entries', 'stream=width,height,codec_name,avg_frame_rate,pix_fmt',
+ '-show_entries', 'format=duration,size',
+ '-of', 'default=noprint_wrappers=1', p]);
+ return out;
+}
+const rep = probe(OUT_PATH);
+const wMatch = /width=(\d+)/.exec(rep), hMatch = /height=(\d+)/.exec(rep);
+const dMatch = /duration=([\d.]+)/.exec(rep);
+const dur = dMatch ? parseFloat(dMatch[1]) : NaN;
+
+console.log('\n[render-short] ==== out.mp4 ffprobe ====');
+console.log(rep.trim());
+console.log('[render-short] ==========================');
+
+const okDims = wMatch && hMatch && +wMatch[1] === W && +hMatch[1] === H;
+const okDur = isFinite(dur) && dur < 60;
+if (!okDims) die(`out.mp4 dimensions are not ${W}x${H}`);
+if (!okDur) die(`out.mp4 duration ${dur}s is not < 60s`);
+
+console.log(`\n[render-short] OK — out.mp4: ${wMatch[1]}x${hMatch[1]}, ${dur.toFixed(2)}s (< 60s), ` +
+ `${silent ? 'SILENT AAC' : 'VO AAC'}.`);
+console.log(`[render-short] out: ${OUT_PATH}`);
+console.log(`[render-short] thumb: ${THUMB_PATH}`);
diff --git a/scripts/short/tts-elevenlabs.mjs b/scripts/short/tts-elevenlabs.mjs
new file mode 100644
index 0000000..35ce56a
--- /dev/null
+++ b/scripts/short/tts-elevenlabs.mjs
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+// tts-elevenlabs.js — synthesize the daily-Short narration to data/short/vo.mp3 via ElevenLabs.
+// Metered (~cents/run). Reads ELEVENLABS_API_KEY from ~/Projects/secrets-manager/.env.
+// Voice defaults to Sarah (confident news-anchor tv voice); override with AND_TTS_VOICE.
+// Model eleven_turbo_v2_5 = cheapest tier with strong quality — good for a news brief.
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HOME = process.env.HOME || '/Users/macstudio3';
+const SECRETS = path.join(HOME, 'Projects/secrets-manager/.env');
+
+function readEnv(file, key) {
+ try {
+ const line = fs.readFileSync(file, 'utf8').split('\n').find((l) => l.startsWith(key + '='));
+ return line ? line.slice(key.length + 1).trim() : null;
+ } catch { return null; }
+}
+
+const VOICE = process.env.AND_TTS_VOICE || 'EXAVITQu4vr4xnSDxMaL'; // Sarah
+const MODEL = process.env.AND_TTS_MODEL || 'eleven_turbo_v2_5';
+// eleven_turbo_v2_5 billed ~$0.50 / 1k chars at list, far less on paid tiers; we log actual chars.
+const RATE_PER_1K = Number(process.env.AND_TTS_RATE_PER_1K || 0.15);
+
+export async function synthesize({ text, out }) {
+ const KEY = process.env.ELEVENLABS_API_KEY || readEnv(SECRETS, 'ELEVENLABS_API_KEY');
+ if (!KEY) throw new Error('ELEVENLABS_API_KEY not found (env or secrets-manager/.env)');
+ if (!text || !text.trim()) throw new Error('tts: empty narration text');
+
+ const chars = text.length;
+ const estCost = +((chars / 1000) * RATE_PER_1K).toFixed(4);
+ const url = `https://api.elevenlabs.io/v1/text-to-speech/${VOICE}?output_format=mp3_44100_128`;
+ const r = await fetch(url, {
+ method: 'POST',
+ headers: { 'xi-api-key': KEY, 'content-type': 'application/json', accept: 'audio/mpeg' },
+ body: JSON.stringify({
+ text,
+ model_id: MODEL,
+ voice_settings: { stability: 0.5, similarity_boost: 0.75, style: 0.0, use_speaker_boost: true },
+ }),
+ });
+ if (!r.ok) {
+ const body = await r.text().catch(() => '');
+ throw new Error(`ElevenLabs HTTP ${r.status}: ${body.slice(0, 300)}`);
+ }
+ const buf = Buffer.from(await r.arrayBuffer());
+ fs.mkdirSync(path.dirname(out), { recursive: true });
+ fs.writeFileSync(out, buf);
+ return { out, bytes: buf.length, chars, costUSD: estCost, voice: VOICE, model: MODEL };
+}
+
+// CLI: node tts-elevenlabs.js --text "…" --out data/short/vo.mp3 (or --file data/short/script.json)
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ const arg = (n) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : null; };
+ const SKILL_DIR = path.dirname(fileURLToPath(import.meta.url));
+ const out = arg('--out') || path.join(SKILL_DIR, '../../data/short/vo.mp3');
+ let text = arg('--text');
+ const file = arg('--file');
+ if (!text && file) text = JSON.parse(fs.readFileSync(file, 'utf8')).narration;
+ if (!text) { console.error('need --text or --file <script.json>'); process.exit(1); }
+ synthesize({ text, out })
+ .then((r) => console.log(`✓ VO written: ${r.out} (${r.chars} chars, ${(r.bytes / 1024).toFixed(0)}KB, ~$${r.costUSD})`))
+ .catch((e) => { console.error('✗ TTS failed:', e.message); process.exit(1); });
+}
diff --git a/scripts/short/upload-youtube.mjs b/scripts/short/upload-youtube.mjs
new file mode 100644
index 0000000..a9e3711
--- /dev/null
+++ b/scripts/short/upload-youtube.mjs
@@ -0,0 +1,234 @@
+#!/usr/bin/env node
+// upload-youtube.mjs — resumable YouTube upload for the allnewsdaily daily-Short pipeline (TK-11342, STAGE 4).
+// Node v26, raw fetch, NO googleapis dependency.
+//
+// Exports: async uploadShort({ file, title, description, tags, privacyStatus='unlisted', thumbnail }) -> { videoId, url }
+//
+// Cody/DTD red-team hardening (2026-09-09):
+// - TIMEOUTS (#5): every network call has an AbortController deadline (no silent hang).
+// - RESUMABLE RESUME (#6): a dropped byte-PUT is RESUMED from the server's received
+// offset (Content-Range: bytes */size → 308 Range) — it NEVER re-POSTs the init,
+// so a bad-network retry costs 0 extra quota units instead of another 1,600.
+// - THUMBNAIL (#4): uploads data/short/thumb.jpg via thumbnails.set (best-effort).
+// - CLI guard fixed for the .mjs rename.
+//
+// CLI: node scripts/short/upload-youtube.mjs --file data/short/out.mp4 --title "…" [--dry-run] …
+
+import { readFileSync, existsSync, statSync } from 'node:fs';
+import { readFile } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { join, isAbsolute, basename } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const SECRETS_ENV = join(homedir(), 'Projects', 'secrets-manager', '.env');
+const APP_ENV = join(homedir(), 'Projects', 'allnewsdaily', '.env');
+const PROJECT_ROOT = join(homedir(), 'Projects', 'allnewsdaily');
+
+const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
+const RESUMABLE_ENDPOINT = 'https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status';
+const THUMBNAIL_ENDPOINT = 'https://www.googleapis.com/upload/youtube/v3/thumbnails/set';
+const CATEGORY_ID = '25'; // News & Politics
+
+const PUT_ATTEMPTS = 3; // resume tries for the byte upload
+const T_SHORT = 30000; // token/init/status/thumbnail timeout
+const T_PUT = 180000; // byte-PUT timeout
+
+// fetch with an AbortController deadline
+async function tfetch(url, opts = {}, ms = T_SHORT) {
+ const ac = new AbortController();
+ const to = setTimeout(() => ac.abort(), ms);
+ try { return await fetch(url, { ...opts, signal: ac.signal }); }
+ finally { clearTimeout(to); }
+}
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function parseEnv(path) {
+ const out = {};
+ if (!existsSync(path)) return out;
+ for (const raw of readFileSync(path, 'utf8').split('\n')) {
+ const line = raw.trim();
+ if (!line || line.startsWith('#')) continue;
+ const eq = line.indexOf('=');
+ if (eq === -1) continue;
+ let val = line.slice(eq + 1).trim();
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
+ out[line.slice(0, eq).trim()] = val;
+ }
+ return out;
+}
+
+function loadCreds() {
+ const secrets = parseEnv(SECRETS_ENV);
+ const app = parseEnv(APP_ENV);
+ return {
+ clientId: secrets.YOUTUBE_CLIENT_ID || process.env.YOUTUBE_CLIENT_ID,
+ clientSecret: secrets.YOUTUBE_CLIENT_SECRET || process.env.YOUTUBE_CLIENT_SECRET,
+ refreshToken: app.YOUTUBE_REFRESH_TOKEN || secrets.YOUTUBE_REFRESH_TOKEN || process.env.YOUTUBE_REFRESH_TOKEN,
+ };
+}
+
+async function refreshAccessToken({ clientId, clientSecret, refreshToken }) {
+ if (!clientId || !clientSecret) throw new Error(`Missing YOUTUBE_CLIENT_ID/SECRET in ${SECRETS_ENV}`);
+ if (!refreshToken) throw new Error(`Missing YOUTUBE_REFRESH_TOKEN (run youtube-auth.mjs first to authorize; saved to ${APP_ENV})`);
+ const body = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token' });
+ const res = await tfetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok || !json.access_token) {
+ // A revoked/expired refresh token surfaces here as invalid_grant — make it loud.
+ throw new Error(`Access-token refresh failed (${res.status}): ${json.error || ''} ${json.error_description || ''}${json.error === 'invalid_grant' ? ' — refresh token revoked/expired; re-run youtube-auth.mjs' : ''}`);
+ }
+ return json.access_token;
+}
+
+function buildMetadata({ title, description, tags, privacyStatus }) {
+ const tagArr = Array.isArray(tags) ? tags
+ : typeof tags === 'string' && tags.length ? tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
+ return {
+ snippet: { title: title || '', description: description || '', tags: tagArr, categoryId: CATEGORY_ID },
+ status: { privacyStatus: privacyStatus || 'unlisted', selfDeclaredMadeForKids: false },
+ };
+}
+
+const resolveFile = (file) => { if (!file) throw new Error('file is required'); return isAbsolute(file) ? file : join(PROJECT_ROOT, file); };
+
+// Query how many bytes the resumable session has already received (0 on a fresh session).
+async function queryOffset(location, size, accessToken) {
+ const res = await tfetch(location, {
+ method: 'PUT',
+ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Range': `bytes */${size}`, 'Content-Length': '0' },
+ }, T_SHORT);
+ if (res.status === 200 || res.status === 201) { const j = await res.json().catch(() => ({})); return { done: true, json: j }; }
+ if (res.status === 308) {
+ const range = res.headers.get('range'); // e.g. "bytes=0-262143"
+ const m = range && range.match(/bytes=0-(\d+)/);
+ return { done: false, offset: m ? Number(m[1]) + 1 : 0 };
+ }
+ return { done: false, offset: 0 }; // unknown → restart the bytes (still same session, no extra quota)
+}
+
+// Best-effort custom thumbnail (a failure here must NOT fail the upload).
+async function setThumbnail(videoId, file, accessToken) {
+ try {
+ const abs = resolveFile(file);
+ if (!existsSync(abs)) return { ok: false, note: 'thumb not found' };
+ const bytes = await readFile(abs);
+ const res = await tfetch(`${THUMBNAIL_ENDPOINT}?videoId=${videoId}`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'image/jpeg', 'Content-Length': String(bytes.length) },
+ body: bytes,
+ }, T_SHORT);
+ return { ok: res.ok, note: res.ok ? 'set' : `HTTP ${res.status}` };
+ } catch (e) { return { ok: false, note: e.message }; }
+}
+
+export async function uploadShort({ file, title, description, tags, privacyStatus = 'unlisted', thumbnail }) {
+ const abs = resolveFile(file);
+ if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
+ const size = statSync(abs).size;
+
+ const creds = loadCreds();
+ const accessToken = await refreshAccessToken(creds);
+ const metadata = buildMetadata({ title, description, tags, privacyStatus });
+
+ // Step 1 — init the resumable session (ONCE; never re-POSTed on retry → no quota waste).
+ const initRes = await tfetch(RESUMABLE_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json; charset=UTF-8',
+ 'X-Upload-Content-Type': 'video/*',
+ 'X-Upload-Content-Length': String(size),
+ },
+ body: JSON.stringify(metadata),
+ }, T_SHORT);
+ if (!initRes.ok) { const t = await initRes.text().catch(() => ''); throw new Error(`Resumable init failed (${initRes.status}): ${t.slice(0, 400)}`); }
+ const location = initRes.headers.get('location');
+ if (!location) throw new Error('Resumable init returned no Location header');
+
+ // Step 2 — PUT the bytes, RESUMING from the server's offset on any failure.
+ const bytes = await readFile(abs);
+ let offset = 0;
+ let lastErr = null;
+ for (let attempt = 1; attempt <= PUT_ATTEMPTS; attempt++) {
+ try {
+ const slice = offset > 0 ? bytes.subarray(offset) : bytes;
+ const putRes = await tfetch(location, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'video/*', 'Content-Length': String(size - offset), 'Content-Range': `bytes ${offset}-${size - 1}/${size}` },
+ body: slice,
+ }, T_PUT);
+ if (putRes.ok) {
+ const j = await putRes.json().catch(() => ({}));
+ if (j.id) return await finalize(j.id, thumbnail, accessToken);
+ throw new Error(`Byte upload OK but no video id: ${JSON.stringify(j).slice(0, 300)}`);
+ }
+ if (putRes.status === 308) { // incomplete — resume from reported offset
+ const range = putRes.headers.get('range');
+ const m = range && range.match(/bytes=0-(\d+)/);
+ offset = m ? Number(m[1]) + 1 : offset;
+ continue;
+ }
+ const t = await putRes.text().catch(() => '');
+ throw new Error(`Byte upload failed (${putRes.status}): ${t.slice(0, 300)}`);
+ } catch (e) {
+ lastErr = e;
+ if (attempt === PUT_ATTEMPTS) break;
+ await sleep(1500 * attempt); // backoff
+ const q = await queryOffset(location, size, accessToken).catch(() => ({ done: false, offset }));
+ if (q.done && q.json && q.json.id) return await finalize(q.json.id, thumbnail, accessToken);
+ offset = q.offset ?? offset;
+ }
+ }
+ throw new Error(`Byte upload failed after ${PUT_ATTEMPTS} attempts: ${lastErr && lastErr.message}`);
+}
+
+async function finalize(videoId, thumbnail, accessToken) {
+ let thumb = { ok: false, note: 'skipped' };
+ if (thumbnail) thumb = await setThumbnail(videoId, thumbnail, accessToken);
+ return { videoId, url: `https://youtu.be/${videoId}`, thumbnail: thumb };
+}
+
+// --- CLI -------------------------------------------------------------------
+function parseArgs(argv) {
+ const args = { dryRun: false };
+ for (let i = 2; i < argv.length; i++) {
+ const a = argv[i]; const next = () => argv[++i];
+ switch (a) {
+ case '--file': args.file = next(); break;
+ case '--title': args.title = next(); break;
+ case '--description': args.description = next(); break;
+ case '--tags': args.tags = next(); break;
+ case '--thumbnail': args.thumbnail = next(); break;
+ case '--privacy': case '--privacyStatus': args.privacyStatus = next(); break;
+ case '--dry-run': args.dryRun = true; break;
+ default: break;
+ }
+ }
+ return args;
+}
+
+async function cli() {
+ const args = parseArgs(process.argv);
+ if (!args.file) { console.error('Usage: node scripts/short/upload-youtube.mjs --file <path> --title "…" [--thumbnail p] [--privacy unlisted] [--dry-run]'); process.exit(1); }
+ const privacyStatus = args.privacyStatus || 'unlisted';
+ const metadata = buildMetadata({ title: args.title, description: args.description, tags: args.tags, privacyStatus });
+ const abs = resolveFile(args.file);
+ const exists = existsSync(abs);
+ const size = exists ? statSync(abs).size : 0;
+
+ if (args.dryRun) {
+ console.log('\n[upload-youtube] --dry-run — NO upload performed.\n');
+ console.log(`File: ${abs} exists=${exists}${exists ? ` (${size} bytes)` : ' (WARNING: not found)'}`);
+ console.log(`Endpoints:\n 1. POST ${RESUMABLE_ENDPOINT}\n 2. PUT <Location> (resumable, Content-Range)\n thumb: POST ${THUMBNAIL_ENDPOINT}?videoId=…\n token: POST ${TOKEN_ENDPOINT}`);
+ console.log('\nMetadata:\n' + JSON.stringify(metadata, null, 2));
+ console.log(`\ncategoryId=${CATEGORY_ID}, selfDeclaredMadeForKids=false, privacyStatus=${privacyStatus}\n`);
+ return;
+ }
+ try {
+ const r = await uploadShort({ file: args.file, title: args.title, description: args.description, tags: args.tags, privacyStatus, thumbnail: args.thumbnail });
+ console.log(`\n[upload-youtube] Uploaded. videoId=${r.videoId}\n ${r.url}\n thumbnail: ${r.thumbnail.note}\n`);
+ } catch (e) { console.error(`\n[upload-youtube] FAILED: ${e.message}\n`); process.exit(1); }
+}
+
+// Run the CLI only when invoked directly (extension-agnostic — survives the .js→.mjs rename).
+if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) cli();
diff --git a/scripts/short/youtube-auth.mjs b/scripts/short/youtube-auth.mjs
new file mode 100644
index 0000000..94d4892
--- /dev/null
+++ b/scripts/short/youtube-auth.mjs
@@ -0,0 +1,264 @@
+#!/usr/bin/env node
+// youtube-auth.js — OAuth 2.0 loopback flow for the allnewsdaily daily-Short pipeline (TK-11342, STAGE 4).
+// Node v26, raw fetch, NO googleapis dependency.
+//
+// Flow:
+// 1. Read YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET from ~/Projects/secrets-manager/.env
+// 2. Start a localhost server on http://localhost:9964/oauth2callback
+// 3. PRINT the consent URL for Steve to open
+// 4. Catch the ?code=, exchange for tokens
+// 5. SAVE refresh_token to ~/Projects/allnewsdaily/.env as YOUTUBE_REFRESH_TOKEN=
+// 6. channels.list?mine=true&part=snippet → PRINT the authorized channel title
+// 7. On redirect_uri_mismatch, print a clear fix instruction
+//
+// Flags:
+// --print-url-only Build + print the consent URL and exit (no server, for self-test)
+//
+// Usage: node scripts/short/youtube-auth.js (Ctrl-C to abort the wait)
+
+import http from 'node:http';
+import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+const SECRETS_ENV = join(homedir(), 'Projects', 'secrets-manager', '.env');
+const APP_ENV = join(homedir(), 'Projects', 'allnewsdaily', '.env');
+
+const PORT = 9964;
+const REDIRECT_URI = `http://localhost:${PORT}/oauth2callback`;
+const SCOPES = [
+ 'https://www.googleapis.com/auth/youtube.upload',
+ 'https://www.googleapis.com/auth/youtube.readonly',
+];
+
+const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
+const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
+const CHANNELS_ENDPOINT =
+ 'https://www.googleapis.com/youtube/v3/channels?mine=true&part=snippet';
+
+// --- tiny .env parser (no dotenv dependency) -------------------------------
+function parseEnv(path) {
+ const out = {};
+ if (!existsSync(path)) return out;
+ const txt = readFileSync(path, 'utf8');
+ for (const raw of txt.split('\n')) {
+ const line = raw.trim();
+ if (!line || line.startsWith('#')) continue;
+ const eq = line.indexOf('=');
+ if (eq === -1) continue;
+ const key = line.slice(0, eq).trim();
+ let val = line.slice(eq + 1).trim();
+ if (
+ (val.startsWith('"') && val.endsWith('"')) ||
+ (val.startsWith("'") && val.endsWith("'"))
+ ) {
+ val = val.slice(1, -1);
+ }
+ out[key] = val;
+ }
+ return out;
+}
+
+// --- upsert a KEY=value into an .env file, preserving the rest -------------
+function upsertEnv(path, key, value) {
+ let lines = [];
+ if (existsSync(path)) {
+ lines = readFileSync(path, 'utf8').split('\n');
+ }
+ const idx = lines.findIndex((l) => l.trim().startsWith(`${key}=`));
+ const newLine = `${key}=${value}`;
+ if (idx >= 0) {
+ lines[idx] = newLine;
+ } else {
+ // keep a trailing newline tidy
+ if (lines.length && lines[lines.length - 1].trim() === '') {
+ lines.splice(lines.length - 1, 0, newLine);
+ } else {
+ lines.push(newLine);
+ }
+ }
+ let out = lines.join('\n');
+ if (!out.endsWith('\n')) out += '\n';
+ writeFileSync(path, out, { mode: 0o600 });
+}
+
+function loadCreds() {
+ const env = parseEnv(SECRETS_ENV);
+ const clientId = env.YOUTUBE_CLIENT_ID || process.env.YOUTUBE_CLIENT_ID;
+ const clientSecret =
+ env.YOUTUBE_CLIENT_SECRET || process.env.YOUTUBE_CLIENT_SECRET;
+ if (!clientId || !clientSecret) {
+ console.error(
+ `\n[youtube-auth] FATAL: YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET not found in ${SECRETS_ENV}\n` +
+ `Route them via the secrets skill first.\n`
+ );
+ process.exit(1);
+ }
+ return { clientId, clientSecret };
+}
+
+function buildConsentUrl(clientId) {
+ const p = new URLSearchParams({
+ client_id: clientId,
+ redirect_uri: REDIRECT_URI,
+ response_type: 'code',
+ scope: SCOPES.join(' '),
+ access_type: 'offline', // ask for a refresh_token
+ prompt: 'consent', // force a refresh_token even on re-auth
+ include_granted_scopes: 'true',
+ });
+ return `${AUTH_ENDPOINT}?${p.toString()}`;
+}
+
+async function exchangeCodeForTokens(code, clientId, clientSecret) {
+ const body = new URLSearchParams({
+ code,
+ client_id: clientId,
+ client_secret: clientSecret,
+ redirect_uri: REDIRECT_URI,
+ grant_type: 'authorization_code',
+ });
+ const res = await fetch(TOKEN_ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ if (json.error === 'redirect_uri_mismatch') {
+ console.error(
+ `\n[youtube-auth] redirect_uri_mismatch.\n` +
+ `The OAuth client is a "Web application" type and does NOT trust this redirect.\n` +
+ `FIX: In Google Cloud Console → APIs & Services → Credentials → your OAuth 2.0 Client ID,\n` +
+ ` add this EXACT authorized redirect URI:\n\n` +
+ ` ${REDIRECT_URI}\n\n` +
+ ` then re-run this script. (A "Desktop app" client type auto-trusts loopback and needs no registration.)\n`
+ );
+ } else {
+ console.error(
+ `\n[youtube-auth] Token exchange failed (${res.status}): ${json.error || ''} ${json.error_description || ''}\n`
+ );
+ }
+ throw new Error(json.error || `token exchange HTTP ${res.status}`);
+ }
+ return json; // { access_token, refresh_token, expires_in, scope, token_type }
+}
+
+async function getChannelTitle(accessToken) {
+ const res = await fetch(CHANNELS_ENDPOINT, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ console.error(
+ `[youtube-auth] channels.list failed (${res.status}): ${JSON.stringify(json).slice(0, 300)}`
+ );
+ return null;
+ }
+ const item = json.items && json.items[0];
+ return item ? item.snippet.title : null;
+}
+
+async function main() {
+ const printUrlOnly = process.argv.includes('--print-url-only');
+ const { clientId, clientSecret } = loadCreds();
+ const consentUrl = buildConsentUrl(clientId);
+
+ if (printUrlOnly) {
+ console.log('\n[youtube-auth] --print-url-only — consent URL:\n');
+ console.log(consentUrl + '\n');
+ console.log(
+ `redirect_uri : ${REDIRECT_URI}\n` +
+ `scopes : ${SCOPES.join(' , ')}\n` +
+ `(no server started; exiting cleanly)\n`
+ );
+ return;
+ }
+
+ // Start the loopback server, then print the URL.
+ const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, `http://localhost:${PORT}`);
+ if (url.pathname !== '/oauth2callback') {
+ res.writeHead(404).end('Not found');
+ return;
+ }
+ const err = url.searchParams.get('error');
+ const code = url.searchParams.get('code');
+ if (err) {
+ res.writeHead(400, { 'Content-Type': 'text/plain' }).end(
+ `OAuth error: ${err}. You can close this tab.`
+ );
+ console.error(`\n[youtube-auth] Consent returned error: ${err}\n`);
+ server.close();
+ process.exit(1);
+ }
+ if (!code) {
+ res.writeHead(400).end('Missing ?code');
+ return;
+ }
+ try {
+ const tokens = await exchangeCodeForTokens(code, clientId, clientSecret);
+ if (!tokens.refresh_token) {
+ res.writeHead(200, { 'Content-Type': 'text/plain' }).end(
+ 'Authorized, but Google returned NO refresh_token. Revoke access at ' +
+ 'myaccount.google.com/permissions and re-run. You can close this tab.'
+ );
+ console.error(
+ '\n[youtube-auth] No refresh_token returned. Revoke the app at ' +
+ 'https://myaccount.google.com/permissions and re-run (prompt=consent is set).\n'
+ );
+ server.close();
+ process.exit(1);
+ }
+ upsertEnv(APP_ENV, 'YOUTUBE_REFRESH_TOKEN', tokens.refresh_token);
+ const title = await getChannelTitle(tokens.access_token);
+ res.writeHead(200, { 'Content-Type': 'text/plain' }).end(
+ `Authorized${title ? ' as: ' + title : ''}. Refresh token saved. You can close this tab.`
+ );
+ console.log(`\n[youtube-auth] SUCCESS.`);
+ console.log(` refresh_token saved → ${APP_ENV} (YOUTUBE_REFRESH_TOKEN=)`);
+ console.log(
+ ` NOTE: ${APP_ENV} is gitignored. Also route YOUTUBE_REFRESH_TOKEN via the \`secrets\` skill so it fans out to the registry.`
+ );
+ if (title) console.log(` Authorized YouTube channel: "${title}"`);
+ else console.log(` (channels.list returned no channel title)`);
+ server.close();
+ process.exit(0);
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'text/plain' }).end(
+ `Token exchange failed: ${e.message}. See terminal. You can close this tab.`
+ );
+ server.close();
+ process.exit(1);
+ }
+ });
+
+ server.listen(PORT, () => {
+ console.log(`\n[youtube-auth] Loopback server listening on ${REDIRECT_URI}`);
+ console.log(`\n>>> Open this URL in a browser and grant access:\n`);
+ console.log(consentUrl + '\n');
+ console.log('Waiting for the OAuth redirect… (Ctrl-C to abort)\n');
+ });
+
+ server.on('error', (e) => {
+ if (e.code === 'EADDRINUSE') {
+ console.error(
+ `\n[youtube-auth] Port ${PORT} is already in use. Close the other process and retry.\n`
+ );
+ } else {
+ console.error(`\n[youtube-auth] Server error: ${e.message}\n`);
+ }
+ process.exit(1);
+ });
+
+ process.on('SIGINT', () => {
+ console.log('\n[youtube-auth] Aborted (Ctrl-C). No tokens exchanged.');
+ server.close();
+ process.exit(0);
+ });
+}
+
+main().catch((e) => {
+ console.error(`[youtube-auth] Fatal: ${e.message}`);
+ process.exit(1);
+});
← 9a1fdc0 Live static-mode via sentinel file (no prod env/restart) ins
·
back to Allnewsdaily
·
allnewsdaily Short: auto-public + delete-canary (DTD verdict 42d9caf →