diff --git a/packages/cli/src/commands/promote.ts b/packages/cli/src/commands/promote.ts index ad6bf26c..941838c8 100644 --- a/packages/cli/src/commands/promote.ts +++ b/packages/cli/src/commands/promote.ts @@ -1,12 +1,16 @@ import { Command } from 'commander'; import kleur from 'kleur'; import prompts from 'prompts'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { runSetup, type AdapterWithSetup, type SocialPlatform, type SocialPost, getAdapterConfig, + configDir, } from '@profullstack/sh1pt-core'; import { describeInput, resolveInput } from '../input.js'; import { merchCmd } from './merch.js'; @@ -15,6 +19,40 @@ import { makeCliSetupContext } from '../setup-context.js'; import { ensureInstalled, loadInstalledPackage } from '../installer.js'; import { runShell } from './build.js'; +// ── file-based state helpers ────────────────────────────────────────────────── + +async function atomicWritePromote(file: string, data: unknown): Promise { + await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.tmp`; + await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); + await fs.rename(tmp, file); +} + +async function readJsonPromote(file: string, fallback: T): Promise { + try { + const raw = await fs.readFile(file, 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? (parsed as T) : fallback; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return fallback; + throw err; + } +} + +interface OutreachState { + podcasts: Array<{ niche: string; minListeners: number; pitchedAt: string; dryRun: boolean }>; + emails: Array<{ recipients: string; subject: string; sentAt: string; count: number; dryRun: boolean }>; + launches: Array<{ sites: string[]; schedule?: string; tagline?: string; createdAt: string }>; +} +interface BridgeRoute { from: string; to: string[]; filters: string[]; addedAt: string; } +interface BridgeState { routes: BridgeRoute[]; networks: string[]; pid?: number; startedAt?: string; } +interface DocRecord { kind: string; format: string; provider: string; outPath: string; generatedAt: string; } + +const OUTREACH_FILE = () => path.join(configDir(), 'promote-outreach.json'); +const BRIDGE_FILE = () => path.join(configDir(), 'promote-bridge.json'); +const DOCS_FILE = () => path.join(configDir(), 'promote-docs.json'); +const BRIDGE_PID = () => path.join(configDir(), 'promote-bridge.pid'); + export const promoteCmd = new Command('promote') .description('Run ads + ship swag + list in affiliate marketplaces. Reddit, Meta, TikTok, Google, YouTube, X, Apple Search, LinkedIn, Microsoft — plus Printful/Printify merch and CJ/Rakuten/Impact/etc affiliate programs.') .option('--platform ', 'only launch on these platforms') @@ -137,7 +175,7 @@ investorsCmd investorsCmd .command('search') - .description('Search investor database and export CSV without launching') + .description('Search investor database and export CSW without launching') .option('--stage ') .option('--sectors ') .option('--leads-only') @@ -223,7 +261,7 @@ const SOCIAL_PLATFORMS = [ socialCmd .command('setup') .description('Connect social accounts — runs each platform adapter\'s setup (cookie paste / OAuth / token)') - .option('--platform ', 'e.g. x linkedin instagram (or social-x, social-linkedin)') + .option('--platform ', 'e.g. x linkedin instagram (or social-x, social-linkedin)") .action(async (opts: { platform?: string[] }, cmd: Command) => { // Parent `promote` declares its own --platform , so when the user // types `sh1pt promote social setup --platform x y`, commander may bind @@ -312,7 +350,7 @@ const OAUTH_REGISTRATION_GUIDES: OAuthRegistrationGuide[] = [ 'Go to https://developer.x.com/en/portal/projects-and-apps', 'Create a Project, then create an App within it', 'Under "User authentication settings", enable OAuth 2.0 with PKCE', - 'Add the redirect URIs listed below under "Callback URI / Redirect URL"', + 'Add the redirect UTRIs listed below under "Callback URI / Redirect URL"', 'Select "Read and Write" (and "Read and Write and Direct Message" if needed) permissions', 'Copy your Client ID (no client secret for PKCE)', ], @@ -339,7 +377,7 @@ const OAUTH_REGISTRATION_GUIDES: OAuthRegistrationGuide[] = [ redirectUris: ['http://127.0.0.1:8765/callback', 'https://sh1pt.com/auth/callback'], scopes: ['instagram_basic', 'instagram_content_publish', 'pages_show_list'], steps: [ - 'Create or use an existing Meta Business app at https://developers.facebook.com/apps/', + 'Create or nuse an existing Meta Business app at https://developers.facebook.com/apps/', 'Add the "Instagram Basic Display" product', 'Under Instagram Basic Display → "Basic Display", configure OAuth redirect URIs', 'Note your App ID and App Secret from Settings → Basic', @@ -348,569 +386,381 @@ const OAUTH_REGISTRATION_GUIDES: OAuthRegistrationGuide[] = [ { platform: 'tiktok', label: 'TikTok', - url: 'https://developers.tiktok.com/apps/', - docUrl: 'https://developers.tiktok.com/documentation/login-kit-web/manage-user-tokens/', - redirectUris: ['http://127.0.0.1:8765/callback', 'https://sh1pt.com/auth/callback'], - scopes: ['user.info.basic', 'video.publish', 'video.upload'], - steps: [ - 'Go to https://developers.tiktok.com/apps/ and click "Create App"', - 'Fill in your app name, description, and upload icons', - 'Add the redirect URIs listed below under "Redirect URL"', - 'Enable the "Login Kit" and "Content Publishing" permissions', - 'Copy your Client Key (App ID) and Client Secret', - ], - }, - { - platform: 'reddit', - label: 'Reddit', - url: 'https://www.reddit.com/prefs/apps', - docUrl: 'https://github.com/reddit-archive/reddit/wiki/OAuth2', - redirectUris: ['http://127.0.0.1:8765/callback', 'https://sh1pt.com/auth/callback'], - scopes: ['identity', 'submit', 'read', 'edit'], - steps: [ - 'Go to https://www.reddit.com/prefs/apps and click "create another app…"', - 'Choose "web app" type', - 'Set the redirect URI to http://127.0.0.1:8765/callback', - 'Note your Client ID (the string under the app name) and Client Secret', - ], - }, - { - platform: 'google', - label: 'Google (YouTube)', - url: 'https://console.cloud.google.com/apis/credentials', - docUrl: 'https://developers.google.com/youtube/registering_an_application', - redirectUris: ['http://127.0.0.1:8765/callback', 'https://sh1pt.com/auth/callback'], - scopes: ['https://www.googleapis.com/auth/youtube.force-ssl', 'https://www.googleapis.com/auth/youtube.upload'], - steps: [ - 'Go to https://console.cloud.google.com/apis/credentials and create a project', - 'Enable the YouTube Data API v3 from "Library"', - 'Create OAuth 2.0 Client ID → "Web application"', - 'Add the redirect URIs listed below under "Authorized redirect URIs"', - 'Copy your Client ID and Client Secret', - ], - }, - { - platform: 'github', - label: 'GitHub', - url: 'https://github.com/settings/developers', - docUrl: 'https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app', - redirectUris: ['http://127.0.0.1:8765/callback', 'https://sh1pt.com/auth/callback'], - scopes: ['repo', 'workflow', 'user'], - steps: [ - 'Go to https://github.com/settings/developers and click "New OAuth App"', - 'Fill in Application name, Homepage URL, and Authorization callback URL', - 'Add the redirect URIs listed below', - 'Click "Register application"', - 'Copy your Client ID and generate + copy a Client Secret', - ], - }, - { - platform: 'discord', - label: 'Discord', - url: 'https://discord.com/developers/applications', - docUrl: 'https://discord.com/developers/docs/topics/oauth2', - redirectUris: ['http://127.0.0.1:8765/callback', 'https://sh1pt.com/auth/callback'], - scopes: ['identify', 'guilds', 'bot', 'webhook.incoming'], - steps: [ - 'Go to https://discord.com/developers/applications and click "New Application"', - 'Go to the "OAuth2" page and note your Client ID and Client Secret', - 'Add the redirect URIs listed below', - 'If using a bot, go to "Bot" page and create + copy the bot token', - ], - }, - { - platform: 'pinterest', - label: 'Pinterest', - url: 'https://developers.pinterest.com/apps/', - docUrl: 'https://developers.pinterest.com/docs/getting-started/set-up-app/', - redirectUris: ['http://127.0.0.1:8765/callback', 'https://sh1pt.com/auth/callback'], - scopes: ['boards:read', 'boards:write', 'pins:read', 'pins:write', 'user_accounts:read'], - steps: [ - 'Go to https://developers.pinterest.com/apps/ and click "Create app"', - 'Fill in your app name and description', - 'Add the redirect URIs listed below under "Redirect URIs"', - 'Copy your App ID and App Secret', - ], - }, - { - platform: 'spotify', - label: 'Spotify', - url: 'https://developer.spotify.com/dashboard', - docUrl: 'https://developer.spotify.com/documentation/web-api/tutorials/getting-started', - redirectUris: ['http://127.0.0.1:8765/callback', 'https://sh1pt.com/auth/callback'], - scopes: ['user-read-private', 'user-read-email', 'playlist-modify-public', 'playlist-modify-private'], - steps: [ - 'Go to https://developer.spotify.com/dashboard and click "Create App"', - 'Fill in the app name and description', - 'Add the redirect URIs listed below under "Redirect URIs"', - 'Copy your Client ID and Client Secret', - ], - }, - { - platform: 'snapchat', - label: 'Snapchat', - url: 'https://kit.snapchat.com/portal', - docUrl: 'https://docs.snap.com/snap-kit/snap-kit-overview', - redirectUris: ['http://127.0.0.1:8765/callback', 'https://sh1pt.com/auth/callback'], - scopes: ['snapchat-marketing-api', 'business_manager'], - steps: [ - 'Go to https://kit.snapchat.com/portal and log in with a Business account', - 'Create a new app under the Business portal', - 'Enable the OAuth2.0 Client and add the redirect URIs listed below', - 'Copy your OAuth Client ID and Client Secret', - ], - }, - { - platform: 'twitch', - label: 'Twitch', - url: 'https://dev.twitch.tv/console/apps', - docUrl: 'https://dev.twitch.tv/docs/authentication/register-app/', - redirectUris: ['http://127.0.0.1:8765/callback', 'https://sh1pt.com/auth/callback'], - scopes: ['user:read:email', 'chat:read', 'chat:edit', 'channel:manage:broadcast'], - steps: [ - 'Go to https://dev.twitch.tv/console/apps and click "Register Your Application"', - 'Enter a name, add the redirect URIs listed below, and select "Chat Bot" or "Other" category', - 'Copy your Client ID', - 'Click "New Secret" to generate and copy a Client Secret', - ], - }, - { - platform: 'microsoft', - label: 'Microsoft (Azure AD / LinkedIn)', - url: 'https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade', - docUrl: 'https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app', - redirectUris: ['http://127.0.0.1:8765/callback', 'https://sh1pt.com/auth/callback'], - scopes: ['User.Read', 'Mail.Send', 'Files.ReadWrite'], - steps: [ - 'Go to Azure Portal → App Registrations → "New Registration"', - 'Enter a name and select "Accounts in any organizational directory"', - 'Add the redirect URIs listed below (type: Web)', - 'Copy your Application (Client) ID', - 'Create a Client Secret under "Certificates & Secrets" and copy it', - ], - }, -]; - -socialCmd - .command('register') - .description('Walk through registering an OAuth app on a social platform (creates client_id / client_secret in vault)') - .option('--platform ', 'which platform to register on (e.g. facebook, x, linkedin, tiktok, reddit, google, github, discord, pinterest, spotify, twitch)') - .option('--list', 'list all platforms with registration guides') - .action(async (opts: { platform?: string; list?: boolean }) => { - if (opts.list) { - console.log(kleur.bold('\nOAuth App Registration Guides\n')); - for (const guide of OAUTH_REGISTRATION_GUIDES) { - console.log(` ${kleur.cyan(guide.platform.padEnd(12))} ${guide.label}`); - } - console.log(kleur.dim(`\nRun: sh1pt promote social register --platform `)); - return; - } - - let target = opts.platform; - if (!target) { - const res = await prompts({ - type: 'select', - name: 'platform', - message: 'Which platform do you need to register an OAuth app on?', - choices: OAUTH_REGISTRATION_GUIDES.map((g) => ({ title: `${g.label} (${g.platform})`, value: g.platform })), - }); - target = res.platform as string; - } - - const guide = OAUTH_REGISTRATION_GUIDES.find((g) => g.platform === target || g.platform === target.replace(/^social-/, '')); - if (!guide) { - console.log(kleur.red(`No registration guide for "${target}".`)); - console.log(kleur.dim(`Run: sh1pt promote social register --list`)); - return; - } - - console.log(); - console.log(kleur.bold().underline(`Register a ${guide.label} OAuth App`)); - console.log(); - - for (const step of guide.steps) { - console.log(` ${kleur.cyan('‣')} ${step}`); - } - - console.log(); - console.log(kleur.dim(` Required redirect URIs:`)); - for (const uri of guide.redirectUris) { - console.log(` ${kleur.yellow(uri)}`); - } - console.log(); - console.log(kleur.dim(` Required OAuth scopes:`)); - for (const scope of guide.scopes) { - console.log(` ${kleur.green(scope)}`); - } - - console.log(); - const docUrl = guide.docUrl; - console.log(kleur.dim(` Docs: ${docUrl}`)); - console.log(kleur.dim(` Portal: ${guide.url}`)); - console.log(); - - const ctx = makeCliSetupContext(); - const clientId = await ctx.prompt({ - type: 'text', - message: 'Enter the Client ID / App ID from the platform:', - }); - if (clientId) { - await ctx.setSecret(`${guide.platform.toUpperCase()}_CLIENT_ID`, clientId); - } - - const clientSecret = await ctx.prompt({ - type: 'password', - message: 'Enter the Client Secret / App Secret (or leave blank if PKCE):', - }); - if (clientSecret) { - await ctx.setSecret(`${guide.platform.toUpperCase()}_CLIENT_SECRET`, clientSecret); - } - - console.log(); - console.log(kleur.green(` ✓ OAuth app registration details saved for ${guide.label}.`)); - console.log(kleur.dim(` Next step: run "sh1pt promote social setup --platform ${guide.platform}" to complete the OAuth flow.`)); - }); - -function stripSocialPrefix(p: string): string { - return p.replace(/^social-/, '').toLowerCase(); -} - -function inferMediaKind(file: string): 'image' | 'video' | 'gif' { - const lower = file.toLowerCase(); - if (lower.endsWith('.gif')) return 'gif'; - if (/\.(mp4|mov|avi|webm|mkv)$/.test(lower)) return 'video'; - return 'image'; -} - -socialCmd - .command('post') - .description('Cross-post to every connected platform with per-platform adaptation') - .requiredOption('--body ', 'core message — adapters truncate per their limits') - .option('--title ', 'used for long-form (LinkedIn articles, Dev.to, Hashnode)') - .option('--hashtags ', 'comma-separated, no #') - .option('--media ', 'images and/or videos — adapters enforce kind requirements') - .option('--link ', 'CTA URL') - .option('--platform ', 'subset; default: all connected') - .option('--schedule ', 'publish at ISO timestamp; omit for now') - .option('--dry-run') - .action(async (opts: { - body: string; - title?: string; - hashtags?: string; - media?: string[]; - link?: string; - platform?: string[]; - schedule?: string; - dryRun?: boolean; - }) => { - const post: SocialPost = { - body: opts.body, - title: opts.title, - hashtags: opts.hashtags ? opts.hashtags.split(',').map((h) => h.trim()).filter(Boolean) : undefined, - media: opts.media?.map((file) => ({ file, kind: inferMediaKind(file) })), - link: opts.link, - schedule: opts.schedule ? new Date(opts.schedule) : undefined, - }; - - const names = (opts.platform ?? SOCIAL_PLATFORMS).map(stripSocialPrefix).filter(Boolean); + url: 'https://developers.tiktok.��K�\������\�� �΋��]�[�\�˝Z��˘��K���[Y[�][ۋ���[�Z�] ]�X��X[�Y�K]\�\�]��[������Y\�X�\�\Έ�����L�ˌ � �N� +͍K��[�X��� �΋��� \ ���K�]] ��[�X���K����\Έ��\�\��[��˘�\�X�� ݚY[˜X�\� � ݚY[˝\�Y �K��\Έˆ ����΋��]�[�\�˝Z��˘��K�\��[��X���ܙX]H\��� њ[[�[�\�\�[YK\�ܚ\[ۋ[�\�YX�ۜ��� �YH�Y\�X�T�\�\�Y�[��[�\���Y\�X�T���� �[�X�HH���[��]�[���۝[�X�\�[�Ȉ\�Z\��[ۜ��� ���H[�\��Y[��^H +\Q +H[��Y[��Xܙ] ��K�K�ˆ]�ܛN� ܙY] ��X�[� ԙY] ��\�� �΋����˜�Y] ���K��Y���\�����\�� �΋���]X����KܙY] X\��]�KܙY] ��Z�K��]] ����Y\�X�\�\Έ�����L�ˌ � �N� +͍K��[�X��� �΋��� \ ���K�]] ��[�X���K����\Έ��Y[�]I� ��X�Z] � ܙXY � �Y] �K��\Έˆ ����΋����˜�Y] ���K��Y���\�[��X���ܙX]H[��\�\8�)���� �����H��X�\�\I�� ��]H�Y\�X�T�H����L�ˌ � �N� +͍K��[�X���� ӛ�H[�\��Y[�Q +H��[��[�\�H\�[YJH[��Y[��Xܙ] ��K�K�ˆ]�ܛN� �����I��X�[� �����H +[�UX�JI��\�� �΋���ۜ��K���Y �����K���K�\\��ܙY[�X[�����\�� �΋��]�[�\�˙����K���K�[�]X�KܙY�\�\�[���[��\X�][ۉ���Y\�X�\�\Έ�����L�ˌ � �N� +͍K��[�X��� �΋��� \ ���K�]] ��[�X���K����\Έ��΋����˙����X\\˘��K�]] �[�]X�K��ܘ�K\�� � �΋����˙����X\\˘��K�]] �[�]X�K�\�Y �K��\Έˆ ����΋���ۜ��K���Y �����K���K�\\��ܙY[�X[�[�ܙX]HH�ڙX� �� �[�X�HH[�UX�H]HTH�����H�X��\�H��� �ܙX]H�]] ���Y[�Q8�����X�\X�][ۈ��� �YH�Y\�X�T�\�\�Y�[��[�\��]]ܚ^�Y�Y\�X�T�\ȉ�� ���H[�\��Y[�Q[��Y[��Xܙ] ��K�K�ˆ]�ܛN� ��]X���X�[� ��]X���\�� �΋���]X����K��][����]�[�\������\�� �΋����˙�]X����K�[��\���]] X\�؝Z[[��[�]] X\��ܙX][��X[�[�]] X\ ���Y\�X�\�\Έ�����L�ˌ � �N� +͍K��[�X��� �΋��� \ ���K�]] ��[�X���K����\Έ�ܙ\�� ��ܚٛ��� �\�\��K��\Έˆ ����΋���]X����K��][����]�[�\��[��X����]��]]\��� њ[[�\X�][ۈ�[YK�Y\Y�HT� [�]]ܚ^�][ۈ�[�X��T� �� �YH�Y\�X�T�\�\�Y�[���� ��X����Y�\�\�\X�][ۈ��� ���H[�\��Y[�Q[��[�\�]H +���HH�Y[��Xܙ] ��K�K�ˆ]�ܛN� �\��ܙ ��X�[� �\��ܙ ��\�� �΋��\��ܙ ���K�]�[�\���\X�][ۜ�����\�� �΋��\��ܙ ���K�]�[�\��������X����]] ����Y\�X�\�\Έ�����L�ˌ � �N� +͍K��[�X��� �΋��� \ ���K�]] ��[�X���K����\Έ��Y[�Y�I� ��Z[�� ؛� � ��X���˚[���Z[���K��\Έˆ ����΋��\��ܙ ���K�]�[�\���\X�][ۜ�[��X����]�\X�][ۈ��� ����H��]] ��Y�H[���H[�\��Y[�Q[��Y[��Xܙ] �� �YH�Y\�X�T�\�\�Y�[���� �Y�\�[��H�� �������Y�H[�ܙX]H +���HH����[���K�K�ˆ]�ܛN� �[�\�\� ��X�[� �[�\�\� ��\�� �΋��]�[�\�˜[�\�\� ���K�\������\�� �΋��]�[�\�˜[�\�\� ���K������][��\�\�Y ��] ]\ X\ ����Y\�X�\�\Έ�����L�ˌ � �N� +͍K��[�X��� �΋��� \ ���K�]] ��[�X���K����\Έ�؛�\�Μ�XY � ؛�\�Νܚ]I� �[�Μ�XY � �[�Νܚ]I� �\�\��X���[�Μ�XY �K��\Έˆ ����΋��]�[�\�˜[�\�\� ���K�\��[��X���ܙX]H\��� њ[[�[�\�\�[YH[�\�ܚ\[ۉ�� �YH�Y\�X�T�\�\�Y�[��[�\���Y\�X�T�\ȉ�� ���H[�\�\Q[�\�Xܙ] ��K�K�ˆ]�ܛN� ���Y�I��X�[� ���Y�I��\�� �΋��]�[�\����Y�K���K�\���\� ����\�� �΋��]�[�\����Y�K���K���[Y[�][ۋ��X�X\K�]ܚX[���][��\�\�Y ���Y\�X�\�\Έ�����L�ˌ � �N� +͍K��[�X��� �΋��� \ ���K�]] ��[�X���K����\Έ��\�\�\�XY \�]�]I� �\�\�\�XY Y[XZ[ � �^[\� [[�Y�K\X�X�� �^[\� [[�Y�K\�]�]I�K��\Έˆ ����΋��]�[�\����Y�K���K�\���\�[��X���ܙX]H\��� њ[[�H\�[YH[�\�ܚ\[ۉ�� �YH�Y\�X�T�\�\�Y�[��[�\���Y\�X�T�\ȉ�� ���H[�\��Y[�Q[��Y[��Xܙ] ��K�K�ˆ]�ܛN� �ۘ\�] ��X�[� �ۘ\�] ��\�� �΋���] �ۘ\�] ���K�ܝ[ ����\�� �΋����˜ۘ\ ���K�ۘ\ Z�] �ۘ\ Z�] [ݙ\��Y]����Y\�X�\�\Έ�����L�ˌ � �N� +͍K��[�X��� �΋��� \ ���K�]] ��[�X���K����\Έ��ۘ\�] [X\��][��X\I� ؝\�[�\���X[�Y�\��K��\Έˆ ����΋���] �ۘ\�] ���K�ܝ[[���[��]H�\�[�\��X���[� �� �ܙX]HH�]�\[�\�H�\�[�\��ܝ[ �� �[�X�HH�]] ���Y[�[�YH�Y\�X�T�\�\�Y�[���� ���H[�\��]]�Y[�Q[��Y[��Xܙ] ��K�K�ˆ]�ܛN� ��]� ��X�[� ��]� ��\�� �΋��]���]� ����ۜ��K�\�����\�� �΋��]���]� �������]][�X�][ۋܙY�\�\�X\ ����Y\�X�\�\Έ�����L�ˌ � �N� +͍K��[�X��� �΋��� \ ���K�]] ��[�X���K����\Έ��\�\���XY�[XZ[ � ��]��XY � ��]�Y] � ��[��[�X[�Y�N����Y�\� �K��\Έˆ ����΋��]���]� ����ۜ��K�\�[��X����Y�\�\�[�\�\X�][ۈ��� �[�\�H�[YKYH�Y\�X�T�\�\�Y�[��[��[X���]���܈��\���]Y�ܞI�� ���H[�\��Y[�Q �� ��X����]��Xܙ]���[�\�]H[���HH�Y[��Xܙ] ��K�K�ˆ]�ܛN� �ZXܛ��ٝ ��X�[� �ZXܛ��ٝ +^�\�HQ �[��Y[�I��\�� �΋��ܝ[ �^�\�K���K�ݚY]��ZXܛ��ٝ�PQԙY�\�\�Y\��\X�][ۜ�\��YI����\�� �΋��X\���ZXܛ��ٝ ���K�[�]\��^�\�K�X�]�KY\�X�ܞK�]�[� �]ZX���\� \�Y�\�\�X\ ���Y\�X�\�\Έ�����L�ˌ � �N� +͍K��[�X��� �΋��� \ ���K�]] ��[�X���K����\Έ��\�\���XY � �XZ[ ��[� � њ[\˔�XYܚ]I�K��\Έˆ ����^�\�Hܝ[8���\�Y�\��][ۜ�8�����]��Y�\��][ۈ��� �[�\�H�[YH[��[X��X���[��[�[�Hܙ�[�^�][ۘ[\�X�ܞH��� �YH�Y\�X�T�\�\�Y�[�� +\N��X�I�� ���H[�\�\X�][ۈ +�Y[� +HQ �� �ܙX]HH�Y[��Xܙ][�\���\�Y�X�]\� ��Xܙ]Ȉ[���H] ��K�K�N‚����X[�Y� ���[X[� + ܙY�\�\��B� �\�ܚ\[ۊ ��[���Y��Y�\�\�[��[��]]\ۈH���X[]�ܛH +ܙX]\��Y[��Y ��Y[���Xܙ][��][ +I�B� ��[ۊ �K\]�ܛHY�� ��X�]�ܛH��Y�\�\�ۈ +K�ˈ�X�X���� [��Y[�Z����Y] ����K�]X�\��ܙ [�\�\� ��Y�K�]� +I�B� ��[ۊ �K[\� � �\�[]�ܛ\��]�Y�\��][ۈ�ZY\��B� �X�[ۊ\�[�� +�Έ�]�ܛOΈ��[���\�Έ���X[�JHO�ˆY� +�˛\� +Hˆ�ۜ��K����]\���� + ���]]\�Y�\��][ۈ�ZY\���JNˆ�܈ +�ۜ��ZYHو�UUԑQ�T��USӗ��RQT�Hˆ�ۜ��K��� ��]\���X[��ZYK�]�ܛK�Y[� + L�J_H ��ZYK�X�[X +NˆB��ۜ��K����]\��[J��[��� \��[�H���X[�Y�\�\� K\]�ܛHY� +JNˆ�]\��ˆB��]\��]H�˜]�ܛNˆY� +]\��] +Hˆ�ۜ��\�H]�Z]��\�ˆ\N� ��[X� ���[YN� �]�ܛI��Y\��Y�N� ��X�]�ܛH�[�H�YY��Y�\�\�[��]]\ۏ�����X�\Έ�UUԑQ�T��USӗ��RQT˛X\ + +�HO� +�]N� �˛X�[H + �˜]�ܛ_JX �[YN�˜]�ܛHJJK�JNˆ\��]H�\˜]�ܛH\���[��ˆB���ۜ��ZYHH�UUԑQ�T��USӗ��RQT˙�[� + +�HO�˜]�ܛHOOH\��]˜]�ܛHOOH\��] ��\X�J ל���X[ K� ��JNˆY� +Y�ZYJHˆ�ۜ��K����]\���Y +���Y�\��][ۈ�ZYH�܈��\��]H�� +JNˆ�ۜ��K����]\��[J�[��� \��[�H���X[�Y�\�\� K[\� +JNˆ�]\��ˆB���ۜ��K��� +Nˆ�ۜ��K����]\���� + +K�[�\�[�J�Y�\�\�H ��ZYK�X�[H�]]\ +JNˆ�ۜ��K��� +N‚��܈ +�ۜ��\و�ZYK��\�Hˆ�ۜ��K��� ��]\���X[� ��(��_H ��\X +NˆB���ۜ��K��� +Nˆ�ۜ��K����]\��[J�\]Z\�Y�Y\�X�T�\Θ +JNˆ�܈ +�ۜ�\�Hو�ZYK��Y\�X�\�\�Hˆ�ۜ��K��� ��]\��Y[��\�J_X +NˆB��ۜ��K��� +Nˆ�ۜ��K����]\��[J�\]Z\�Y�]]���\Θ +JNˆ�܈ +�ۜ����Hو�ZYK����\�Hˆ�ۜ��K��� ��]\��ܙY[����J_X +NˆB���ۜ��K��� +Nˆ�ۜ���\�H�ZYK���\�ˆ�ۜ��K����]\��[J��Έ ���\�X +JNˆ�ۜ��K����]\��[Jܝ[� ��ZYK�\�X +JNˆ�ۜ��K��� +N‚��ۜ��HXZ�P�T�]\�۝^ + +Nˆ�ۜ��Y[�YH]�Z]� ���\��[�ϊˆ\N� �^ ��Y\��Y�N� �[�\�H�Y[�Q �\Q���HH]�ܛN���JNˆY� +�Y[�Y +Hˆ]�Z]� ��]�Xܙ] + ��ZYK�]�ܛK��\\��\�J +_W��QS��Q �Y[�Y +NˆB���ۜ��Y[��Xܙ]H]�Z]� ���\��[�ϊˆ\N� �\���ܙ ��Y\��Y�N� �[�\�H�Y[��Xܙ] �\�Xܙ] +܈X]�H�[��Y���JN���JNˆY� +�Y[��Xܙ] +Hˆ]�Z]� ��]�Xܙ] + ��ZYK�]�ܛK��\\��\�J +_W��QS���PԑU �Y[��Xܙ] +NˆB���ۜ��K��� +Nˆ�ۜ��K����]\��ܙY[�8�$��]]\�Y�\��][ۈ]Z[��]�Y�܈ ��ZYK�X�[K� +JNˆ�ۜ��K����]\��[J�^�\��[��� \��[�H���X[�]\ K\]�ܛH ��ZYK�]�ܛ_H����\]HH�]]��˘ +JNˆJN‚��[��[ۈ��\���X[�Y�^ +���[��N���[��ˆ�]\�� ��\X�J ל���X[ K� ��K����\��\�J +NŸB���[��[ۈ[��\�YYXR�[� +�[N���[��N� �[XY�I� ݚY[�� ��Y��ˆ�ۜ���\�H�[K����\��\�J +NˆY� +��\��[���] + ˙�Y��JH�]\�� ��Y��ˆY� + � �\ +[ݟ]�_�X�_Z݊I ˝\� +��\�JH�]\�� ݚY[��ˆ�]\�� �[XY�I�ŸB�����X[�Y� ���[X[� + ��� �B� �\�ܚ\[ۊ �ܛ���\���]�\�H�ۛ�X�Y]�ܛH�]\�\]�ܛHY\][ۉ�B� ��\]Z\�Y�[ۊ �KX��H^�� ��ܙHY\��Y�H8�%Y\\���[��]H\�Z\�[Z]��B� ��[ۊ �K]]H^�� �\�Y�܈ۙ�Y�ܛH +[��Y[�\�X�\�]���\���JI�B� ��[ۊ �KZ\�Y��\��� ���[XK\�\\�]Y ����B� ��[ۊ �K[YYXH] ����� �[XY�\�[� �܈�Y[��8�%Y\\��[��ܘ�H�[��\]Z\�[Y[���B� ��[ۊ �K[[��\��� ��HT� �B� ��[ۊ �K\]�ܛHY ����� ��X��]�Y�][�[�ۛ�X�Y �B� ��[ۊ �K\��Y[H\�ω� �X�\�]T��[Y\�[\��Z]�܈����B� ��[ۊ �KY�K\�[��B� �X�[ۊ\�[�� +�Έˆ��N���[��ˆ]OΈ��[��ˆ\�Y��Έ��[��ˆYYXOΈ��[���Nˆ[��Έ��[��ˆ]�ܛOΈ��[���Nˆ��Y[OΈ��[��ˆ�T�[�Έ���X[�ˆJHO�ˆ�ۜ�������X[��Hˆ��N��˘��K�]N��˝]K�\�Y�Έ�˚\�Y����˚\�Y�˜�] + � �K�X\ + + +HO� ��[J +JK��[\����X[�H�[�Y�[�Y �YYXN��˛YYXO˛X\ + +�[JHO� +��[K�[��[��\�YYXR�[� +�[JHJJK�[�Έ�˛[�����Y[N��˜��Y[H��]�]J�˜��Y[JH�[�Y�[�Y �N‚��ۜ��[Y\�H +�˜]�ܛH�����PS�U�ԓT�K�X\ +��\���X[�Y�^ +K��[\����X[�N‚�Y� +�˙�T�[�Hˆ�ۜ��K����]\���X[� ��K\�[�����X[���]�Y]���JNˆ�܈ +�ۜ��[YHو�[Y\�Hˆ�ۜ���H�ٝ[�X���� \ \���X[ Iۘ[Y_Xˆ]Y\\�����X[]�ܛO[�ۛ�ۏ�[�Y�[�Yˆ�HˆY\\�H]�Z]�Y[��[YX��Y�O���X[]�ܛO[�ۛ�ۏ����NˆH�]�ˆ ����[��[Y8�%��\�B�Y� +XY\\�Hˆ�ۜ��K����]\��[J ۘ[Y_N���[��[Y8�%�[��� \��[�H���X[�]\ K\]�ܛH ۘ[Y_X +JNˆ�۝[�YNˆB��ۜ�X^HY\\���\]Z\�\�˛X^��P�\��ˆ�ۜ��[��]YHX^ ���� ���K�[���X^��� ���K��X�J X^ H �H +� ˋ������ ���Nˆ�ۜ��K����]\���� + �Y\\��X�[���[Y_X +JNˆ�ۜ��K�����H + ��[��]Y �[��H�\��N� ��[��]Y ��X�J  +_I��[��]Y �[��� � ��)��� ��X +NˆY� +�� �\�Y��˛[�� +H�ۜ��K���\�Y�Έ ��� �\�Y�˛X\ + + +HO���X +K���[� � �_X +NˆY� +�� �[��H�ۜ��K���[�Έ ��� �[��X +NˆY� +�� ���Y[JH�ۜ��K�����Y[N� ��� ���Y[K��T����[�� +_X +NˆB��]\��ˆB��][�T��YH�[�Nˆ�܈ +�ۜ��[YHو�[Y\�Hˆ�ۜ���H�ٝ[�X���� \ \���X[ Iۘ[Y_Xˆ]Y\\�����X[]�ܛO[�ۛ�ۏ�[�Y�[�Yˆ�HˆY\\�H]�Z]�Y[��[YX��Y�O���X[]�ܛO[�ۛ�ۏ����NˆH�]�ˆ ����[��[Y�B�Y� +XY\\�Hˆ�ۜ��K����]\��[J ۘ[Y_N���[��[Y8�%��\[�� +JNˆ�۝[�YNˆB���ۜ�Y\\��ۙ�Y�H]�Z]�]Y\\��ۙ�Y�Y\\��Y +NˆY� +XY\\��ۙ�Y�Hˆ�ۜ��K����]\��Y[�� ۘ[Y_N����ۙ�Y�\�Y8�%�[��� \��[�H���X[�]\ K\]�ܛH ۘ[Y_X +JNˆ�۝[�YNˆB���ۜ��Hˆ�Xܙ]� +Έ��[��HO����\�˙[����K��Έ +N���[��HO��ۜ��K����]\��[J�ۘ[Y_WH �_X +JK��T�[���[�K�N‚��Hˆ�ۜ��K����]\���� +��[��� �Y\\��X�[���[Y_x�)� +JNˆ]�Z]Y\\���ۛ�X� +� Y\\��ۙ�Y�Nˆ�ۜ��\�[H]�Z]Y\\���� +� �� Y\\��ۙ�Y�Nˆ�ۜ��K����]\��ܙY[�8�$� �Y\\��X�[���[Y_H0�� ܙ\�[ �\�X +JNˆ[�T��YH�YNˆH�]� +\��Hˆ�ۜ��K�\��܊�]\���Y +8�%� ۘ[Y_N� �\��[��[��[و\��܈�\���Y\��Y�H���[��\��_X +JNˆB�B��Y� +X[�T��Y +Hˆ�ۜ��K����]\��Y[�� ����]�ܛ\���Y8�%�]\X���[���]�� \��[�H���X[�]\ �JNˆB�JN‚����X[�Y� ���[X[� + �Y]�X���B� �\�ܚ\[ۊ �Y�ܙY�]Y[��Y�[Y[�Xܛ����X�[�����B� ��[ۊ �K\]�ܛHY��B� ��[ۊ �KZ��ۉ�B� �X�[ۊ +�Έ�]�ܛOΈ��[�����ۏΈ���X[�JHO�ˆY� +�˚��ۊH��ۜ��K�����Ӌ���[��Y�J���Έ�K�[Έ�HK�[  �JN��]\���B��ۜ��K����]\��[J ���X�H���X[Y]�X���JNˆJN‚���RH�ݚY\��8�%�[�\�]HY��H ����X[��Y\� �Y�[�\����HB�����\ �\�[�����HY�[��� +�X�ܘ\�[��[Y�H�[�\�Y\‹��Z�H�]YX ���^ +N�\�\� PTKX�\�Y�۝[��[�\�][ۂ����^YYٙ��ݚY\�TH�^\�[[�H�][ ���ۜ�RW�U�ԓT�Hˆ ���X[[�Yܘ][ۜˆ ��]YI� ��[�ZI� �]�[�� ��[Z[�I�� ���S���X�� +�[���]\�X��\]X�H�ݚY\��8�%[\[Y[�][ۜ�[�� ���\�KX�KX�\�N��]\ + +H��X��HTH�^H[��H�][�^JB� �ZL�I� �Z[ۛX��� �Z�\�[ � �[X�X�KX��Y � �[X^�ۋX�Y����� �\��YI�� �]\���Y � �^�\�I� ؘZYI� ؘ\�][�� ��\�X��\�� ��]\�� ��\�Y�ZI�� ���Y�\�I� ���\�I� �Y\[���I� �Y\�YZ�� ٙX]\�\��� ٚ\�]�ܚ���� ٜ�Y[�I� ��ZX��Y � �����K]�\�^ � �ܛ�I� �[��\[ۉ� �[��\�ۉ�� �[��\�X]X�� �[��X�[ۉ� �[ۙ] � ��[ZI� �\]ZY � �X[��\�� �Z[�[X^ �� �Z\��[ � �[�ۜ�� � �[ܜ � ۙX�]\�� ۙ^�] � ۛݚ]I�� ��[�[��\�[��I� �\�\�Z[ � �\��\�ۉ� �\�^]I� �[I� ܙZ�I�� ܙ[X�I� ��[X�[�ݘI� ��[X�ۙ���� ��\�[�� ���]��[� � ���]\��� ݙ[�X�I� ��[��� �ZI� �X[�ZI� ޘZI��N‚��ۜ�ZP�YH��[�P�Y� ���[X[� + �ZI�B� �\�ܚ\[ۊ ��ۙ�Y�\�HRH�ݚY\�� +�]YK�[�RK]�[��[Z[�H +� +L +��S���X��H\�Y��Y�Y��H[�����Y\��N‚�ZP�Y� ���[X[� + ��]\ �B� �\�ܚ\[ۊ��ۛ�X�RH�ݚY\��8�%�[��XX��ݚY\�Y\\����]\ +TH�^H\�JH�B� ��[ۊ �K\]�ܛHY ����� �K�ˈ�]YH�[�ZH +܈ZKX�]YKZK[�[�ZJI�B� �X�[ۊ\�[�� +�Έ�]�ܛOΈ��[���HK�Y���[X[� +HO�ˆ�ۜ�Y\��YH�Y ����]�ؘ[� +H\��]�ܛOΈ��[���HNˆ�ۜ��\]Y\�YHY\��Y �]�ܛH���˜]�ܛNˆ]�[Y\�H +�\]Y\�Y���JK�X\ +��\ZT�Y�^ +K��[\����X[�N‚�Y� +�[Y\˛[��OOH +Hˆ�ۜ��\�H]�Z]��\�ˆ\N� �][\�[X� ���[YN� �X�����Y\��Y�N� ��X�RH�ݚY\����]\�����X�\ΈRW�U�ԓT˛X\ + + +HO� +�]N� �[YN�JJK�[���X�[ۜΈ�[�K�[�� ��X�H��[X� �]\����ۙ�\�I��JNˆ�[Y\�H +�\˜X���\���[���H[�Y�[�Y +H���NˆY� +�[Y\˛[��OOH +Hˆ�ۜ��K����]\��[J ۛ�[���[X�Y8�%X�ܝ[�ˉ�JNˆ�]\��ˆB�B���ۜ��[�YH�[Y\˛X\ + +�HO��ٝ[�X���� \ XZKI۟X +Nˆ�Hˆ]�Z][��\�R[��[Y +�[�Y +NˆH�]� +\��Hˆ�ۜ��K�\��܊�]\���Y +\��[��[��[و\��܈�\���Y\��Y�H���[��\��JJNˆ���\�˙^] + JNˆB���ۜ��HXZ�P�T�]\�۝^ + +Nˆ�܈ +�ۜ��[YHو�[Y\�Hˆ�ۜ��K��� +Nˆ�ۜ��K����]\���� + +K�[�\�[�JZN� ۘ[Y_X +JNˆ�ۜ���H�ٝ[�X���� \ XZKIۘ[Y_Xˆ�ۜ�Y\\�H]�Z]�Y[��[YX��Y�OY\\��]�]\���NˆY� +XY\\�\[وY\\�OOH �ؚ�X� �J �Y �[�Y\\�JHˆ�ۜ��K����]\��Y[���Z[Y��Y ���HY�\�[��[8�%�[H[�\��YK� +JNˆ�۝[�YNˆB�]�Z]�[��]\ +Y\\�� +NˆB�JN‚��[��[ۈ��\ZT�Y�^ +���[��N���[��ˆ�]\�� ��\X�J טZKK� ��K����\��\�J +NŸB����Y��[X]K[�]�ܚ�X\��]X�\�8�%�\�\�و���X[[�ZX�]�܂���\��ܛX[��H\��\�ˈ� \\�\�\�\X�[HHY\��[� +\�[�‹��Z\���X�[�H�]�ܚ���X�\�\���[���[�H]�܈B�����[Z\��[ۊK�Y�X[�H�]�ܚ���\ܝ���Y\˂��ۜ�Q��SPUWӑU�Ԓ��Hˆ �ډ� ܘZ�][�� ��\�X\�[I� �]�[�� �[\X� � �\��\��X��� ܙY�\��[ۉ�� �[X^�ۋX\����X]\�� �X�^K\\��\�� ��X�ؘ[��� ���[[[���� ��ݜ���� ٛ^ٙ�\��� �]�[��]I� ��YY�X�\�� ڝ����� �Y�\�ܙL� �� �\�[X]I� �]�\����� �YZ]Y ��N‚��ۜ�Y��[X]\��YH��[�P�Y� ���[X[� + �Y��[X]\��B� �\�ܚ\[ۊ �Y��[X]H�]�ܚ�X\��]X�\�8�%ҋ�Z�][��\�PT�[K]�[�[\X� [X^�ۈ\����X]\��X�И[��[�[ܙI�N‚�Y��[X]\��Y� ���[X[� + ��]\ �B� �\�ܚ\[ۊ��ۛ�X�Y��[X]H�]�ܚ��8�%�[��XX��]�ܚ�Y\\����]\ +TH�^H\�JH�B� ��[ۊ �K[�]�ܚ�Y ����� �K�ˈڈ�Z�][�[\X� +܈Y��[X]KXڋY��[X]KZ[\X� +I�B� �X�[ۊ\�[�� +�Έ��]�ܚ�Έ��[���HK�Y���[X[� +HO�ˆ�ۜ�Y\��YH�Y ����]�ؘ[� +H\���]�ܚ�Έ��[���N�]�ܛOΈ��[���HNˆ�ۜ��\]Y\�YHY\��Y ��]�ܚ����˛�]�ܚ���Y\��Y �]�ܛNˆ]�[Y\�H +�\]Y\�Y���JK�X\ +��\Y��[X]T�Y�^ +K��[\����X[�N‚�Y� +�[Y\˛[��OOH +Hˆ�ۜ��\�H]�Z]��\�ˆ\N� �][\�[X� ���[YN� �X�����Y\��Y�N� ��X�Y��[X]H�]�ܚ����]\�����X�\ΈQ��SPUWӑU�Ԓ�˛X\ + + +HO� +�]N� �[YN�JJK�[���X�[ۜΈ�[�K�[�� ��X�H��[X� �]\����ۙ�\�I��JNˆ�[Y\�H +�\˜X���\���[���H[�Y�[�Y +H���NˆY� +�[Y\˛[��OOH +Hˆ�ۜ��K����]\��[J ۛ�[���[X�Y8�%X�ܝ[�ˉ�JNˆ�]\��ˆB�B���ۜ��[�YH�[Y\˛X\ + +�HO��ٝ[�X���� \ XY��[X]KI۟X +Nˆ�Hˆ]�Z][��\�R[��[Y +�[�Y +NˆH�]� +\��Hˆ�ۜ��K�\��܊�]\���Y +\��[��[��[و\��܈�\���Y\��Y�H���[��\��JJNˆ���\�˙^] + JNˆB���ۜ��HXZ�P�T�]\�۝^ + +Nˆ�܈ +�ۜ��[YHو�[Y\�Hˆ�ۜ��K��� +Nˆ�ۜ��K����]\���� + +K�[�\�[�JY��[X]N� ۘ[Y_X +JNˆ�ۜ���H�ٝ[�X���� \ XY��[X]KIۘ[Y_Xˆ�ۜ�Y\\�H]�Z]�Y[��[YX��Y�OY\\��]�]\���NˆY� +XY\\�\[وY\\�OOH �ؚ�X� �J �Y �[�Y\\�JHˆ�ۜ��K����]\��Y[���Z[Y��Y ���HY�\�[��[8�%�[H[�\��YK� +JNˆ�۝[�YNˆB�]�Z]�[��]\ +Y\\�� +NˆB�JN‚��[��[ۈ��\Y��[X]T�Y�^ +���[��N���[��ˆ�]\�� ��\X�J טY��[X]KK� ��K����\��\�J +NŸB��Y��[X]\��Y� ���[X[� + �\� �B� �\�ܚ\[ۊ �\�]�Z[X�HY��[X]H�]�ܚ�Y\\���B� ��[ۊ �KZ��ۉ�B� �X�[ۊ +�Έ���ۏΈ���X[�JHO�ˆY� +�˚��ۊHˆ�ۜ��K�����Ӌ���[��Y�J��]�ܚ�ΈQ��SPUWӑU�Ԓ��K�[  �JNˆ�]\��ˆB��ۜ��K����]\��[J]�Z[X�N� �Q��SPUWӑU�Ԓ�˚��[� � �_X +JNˆJN‚�Y��[X]\��Y� ���[X[� + �ܙX]K\��ܘ[I�B� �\�ܚ\[ۊ �\�[�\���X�\�HY\��[���ܘ[H[�H�ۛ�X�Y�]�ܚ��B� ��\]Z\�Y�[ۊ �K[�]�ܚ�Y�� �K�ˈڋ[\X� \��\��X���B� ��\]Z\�Y�[ۊ �K[�[YH^�� ���ܘ[H�[YI�B� ��\]Z\�Y�[ۊ �KY\�[�][ۈ\��� ��\�H�X�����[[� �B� ��[ۊ �KX��[Z\��[ۈ�]O�� ۝[Y\�X�8�% �H � H +\��[�Y�JH܈ �H � +�] +I��[X�\� � +B� ��[ۊ �KX��[Z\��[ۋ]\H�[��� �\��[�Y�H�]Y\�Y � �\��[�Y�I�B� ��[ۊ �KX����YKY^\���� �]�X�][ۈ�[�����[X�\� � +B� ��[ۊ �KX�]Y�ܞH�[��� ��X\�X��[Y\��H�[�[��H�\�� ��X\��B� ��[ۊ �KX�\��[��H��O�� �T�� + �M� +�܈�]��[Z\��[ۜ�I� �T� �B� ��[ۊ �KY�K\�[��B� �X�[ۊ +��HO�ˆ�ۜ��K����]\��ܙY[���X�HY��[X]\�ܙX]K\��ܘ[H Ҕ�Ӌ���[��Y�J��_X +JNˆJN‚�Y��[X]\��Y� ���[X[� + ��]��B� �\�ܚ\[ۊ �Y�ܙY�]Y�X��� ��۝�\��[ۜ� ���[Z\��[ۜ�Xܛ����]�ܚ���B� ��[ۊ �K[�]�ܚ�Y�� ٚ[\��ۙH�]�ܚ��B� ��[ۊ �KZ��ۉ�B� �X�[ۊ +�Έ��]�ܚ�Έ��[�����ۏΈ���X[�JHO�ˆY� +�˚��ۊHˆ�ۜ��K�����Ӌ���[��Y�J��]�ܚ�Έ�K�[Έ�X�\�\�Έ �X��Έ �۝�\��[ۜΈ �]�[�YN� ��[Z\��[ۜ�ZY� HK�[  �JNˆ�]\��ˆB��ۜ��K����]\��[J��X�HY��[X]\��]�0���]�ܚ�I��˛�]�ܚ��� �[ �X +JNˆJN‚�ZP�Y� ���[X[� + �\� �B� �\�ܚ\[ۊ �\��ۙ�Y�\�YRH�ݚY\���B� ��[ۊ �KZ��ۉ�B� �X�[ۊ +�Έ���ۏΈ���X[�JHO�ˆY� +�˚��ۊH��ۜ��K�����Ӌ���[��Y�J��ݚY\�ΈRW�U�ԓT�K�[  �JN��]\���B��ۜ��K����]\��[J]�Z[X�N� �RW�U�ԓT˚��[� � �_X +JNˆJN‚����]�XX�[X��[H8�%��\�����[����[XZ[ ][���]\˂���[�][���[\�H�H�[�]]�X]H�^[ۙZYY�[�X�X���˂��ۜ��]�XX��YH��[�P�Y� ���[X[� + ��]�XX� �B� �\�ܚ\[ۊ ���\����[XZ[ ][���]\�8�%[�][���[\�H]��[\��N‚��]�XX��Y� ���[X[� + ���\���B� �\�ܚ\[ۊ �\��ݙ\��[]�[���\�� +��[��Y\� \]�[XZ[� +\�[���\� +��\�[� +I�B� ��[ۊ �K[�X�H\��� ���[XK\�\\�]Y�X�\� � �ZK�\�\�]�����B� ��[ۊ �K[Z[�[\�[�\����� �Z[�[][H\�[�\���[��[\���[X�\� +L +B� ��[ۊ �K[[��XY�H��O�� �� �[��B� ��[ۊ �KYX��]�� �YYXH�] �]�X���B� ��[ۊ �KY�K\�[��B� �X�[ۊ\�[�� +�Έ��X�N���[���Z[�\�[�\�Έ�[X�\��[��XY�N���[���X��Έ��[����T�[�Έ���X[�JHO�ˆ�ۜ��X�\�H�˛�X�K��] + � �K�X\ + +�HO����[J +JNˆ�ۜ��K����]\���� + ����\��]�XX� �JNˆ�ۜ��K����X�\Έ ��]\���X[��X�\˚��[� � �J_X +Nˆ�ۜ��K���Z[�[\�[�\�Έ ��]\���X[���[���˛Z[�\�[�\��J_X +Nˆ�ۜ��K���[��XY�N� ��]\���X[��˛[��XY�J_X +NˆY� +�˙X��H�ۜ��K���X�Έ ��]\���X[��˙X��_X +N‚��ۜ�\R�^HH���\�˙[���T�S�ӓ�T��TW��VNˆY� +X\R�^JHˆ�ۜ��K����]\��Y[�� ��[���]T�S�ӓ�T��TW��VH�[�X�H]�H��\��X\�� �JNˆ�ۜ��K����]\��[J �8���΋����˛\�[���\˘��K�\K��JNˆH[�Hˆ�ۜ��K����]\��[J ���X\��[��\�[���\��)��JNˆ�ۜ�\�H΋��\�[�X\K�\�[���\˘��K�\K݌���X\���OI�[���UT�P��\ۙ[� +�X�\��J_I�\O\��\� �[��XY�OI��˛[��XY�_I��ܝ؞W�]OLˆ�ۜ��H�]۔�[�� ��\� ���\�� �R � S\�[�TKR�^N� �\R�^_X \�K�[���[�Έ �]� �JNˆY� +���]\�OOH +Hˆ�Hˆ�ۜ�]HH��Ӌ�\��J����] +H\���\�[�Έ\��^O�]W�ܚY�[�[���[����[�\\��\Έ�[X�\���X��]OΈ��[��O�Nˆ�ۜ�]�H +]K��\�[����JK��[\� + +HO� ��[�\\��\��H�˛Z[�\�[�\��NˆY� +]˛[��OOH +Hˆ�ۜ��K����]\��[J ����\�[�X�ݙH\�[�\��\�� �JNˆH[�Hˆ�ۜ��K����]\���� + ��X]�[����\�Ή�JNˆ�܈ +�ۜ�و]˜�X�J  L +JB��ۜ��K��� ��]\���X[� �]W�ܚY�[�[ +_H ��]\��[J ��X��]H�� ��_X +NˆB�H�]���ۜ��K����]\��[J ���[��\��H\�[���\��\�ۜ�I�JN�B�B�B��Y� +�˙�T�[�Hˆ�ۜ��K����]\��Y[�� ���K\�[�8�%��]�\��[� �JNˆ�]\��ˆB���ۜ��\�[��^HH���\�˙[����T�S��TW��VNˆY� +\�\�[��^JHˆ�ۜ��K����]\��Y[�� ��[���]�T�S��TW��VH�[�X�H]�[XZ[�[�[���JNˆ�ۜ��K����]\��[J �8���΋�ܙ\�[� ���K��JNˆH[�Hˆ�ۜ��K����]\��[J ����[�[�]�[XZ[��XH�\�[�8�%�ۙ�Y�\�H KYX��[�H�X�\Y[�\� �JNˆB���ۜ��]HH]�Z]�XY��۔��[�O�]�XX��]O��U�PP�ђSJ +K���\�Έ�K[XZ[Έ�K][��\Έ�HJNˆ�]K���\�˜\� +��X�N��˛�X�KZ[�\�[�\�Έ�˛Z[�\�[�\��]�Y]��]�]J +K��T����[�� +K�T�[��H[�˙�T�[�JNˆ]�Z]]�ZX�ܚ]T��[�J�U�PP�ђSJ +K�]JNˆ�ۜ��K����]\��[J ���[��]�Y8�%� \��[�H�]�XX��]\���]�Y]��JNˆJN‚��]�XX��Y� ���[X[� + �[XZ[ �B� �\�ܚ\[ۊ ���[XZ[�\]Y[��H�XH�\�[�8�%�S�T�SH ��T� �����\X[��H\�[�\��\�ۜ�X�[]I�B� ��\]Z\�Y�[ۊ �K\�X�\Y[���ݔ]�� ��Ո�][XZ[ �[YK��\[�K ����B� ��\]Z\�Y�[ۊ �K\�X��X�^��B� ��\]Z\�Y�[ۊ �KX��H]�� �X\���ۋ�[��H�[H�]��X�Z�\��_I�B� ��[ۊ �KY���HY��� �]\��HH�\�Y�YY�\�[��XZ[��B� ��[ۊ �K\�]H\��\��� �X^�[��\��\���[X�\� � +B� ��[ۊ �KY�K\�[��B� �X�[ۊ\�[�� +�Έ��X�\Y[�Έ��[����X��X����[�����N���[������OΈ��[����]N��[X�\���T�[�Έ���X[�JHO�ˆ�ۜ��K����]\���� + ����[XZ[�\]Y[��I�JNˆ�ۜ��K����X�\Y[�Έ ��]\���X[��˜�X�\Y[��_X +Nˆ�ۜ��K����X��X�� ��]\���X[��˜�X��X� +_X +Nˆ�ۜ��K�����N� ��]\���X[��˘��J_X +Nˆ�ۜ��K����]N� ��]\���X[���[���˜�]JJ_K�� +N‚�]�ݐ�۝[����[��ˆ�Hˆ�ݐ�۝[�H]�Z]�˜�XY�[J�˜�X�\Y[�� �]� �NˆH�]�ˆ�ۜ��K�\��܊�]\���Y +�[����XY�X�\Y[���[N� ��˜�X�\Y[��X +JNˆ���\�˙^] + JNˆB��ۜ�����H�ݐ�۝[� ��[J +K��] + ���K��[\����X[�Nˆ�ۜ�XY\�H�����K��] + � �K�X\ + + +HO� ��[J +K����\��\�J +JNˆ�ۜ�[XZ[YHXY\��[�^ي �[XZ[ �NˆY� +[XZ[YOOH LJH��ۜ��K�\��܊�]\���Y + ��Ո]\�]�H[��[XZ[���[[��JN����\�˙^] + JN�B��ۜ��X�\Y[��H���˜�X�J JK�X\ + +�HO����] + � �V�[XZ[YO˝�[J +JK��[\����X[�N‚��ۜ��K����]\��[J� ܙX�\Y[�˛[��H�X�\Y[����[� +JNˆ�܈ +�ۜ�Y�و�X�\Y[�˜�X�J  +JJH�ۜ��K��� ��]\��[J ���_H �Y�X +NˆY� +�X�\Y[�˛[��� +JH�ۜ��K����]\��[J… and ${recipients.length - 5} more`)); if (opts.dryRun) { - console.log(kleur.cyan('dry-run: social post preview\n')); - for (const name of names) { - const pkg = `@profullstack/sh1pt-social-${name}`; - let adapter: SocialPlatform | undefined; - try { - adapter = await loadInstalledPackage>(pkg); - } catch { - // not installed — skip - } - if (!adapter) { - console.log(kleur.dim(` ${name}: not installed — run: sh1pt promote social setup --platform ${name}`)); - continue; - } - const max = adapter.requires?.maxBodyChars; - const truncated = max && post.body.length > max ? post.body.slice(0, max - 3) + '...' : post.body; - console.log(kleur.bold(` ${adapter.label ?? name}`)); - console.log(` body (${truncated.length} chars): ${truncated.slice(0, 80)}${truncated.length > 80 ? '…' : ''}`); - if (post.hashtags?.length) console.log(` hashtags: ${post.hashtags.map((h) => `#${h}`).join(' ')}`); - if (post.link) console.log(` link: ${post.link}`); - if (post.schedule) console.log(` schedule: ${post.schedule.toISOString()}`); - } + console.log(kleur.yellow('\ndry-run — no emails sent')); + const state = await readJsonPromote(OUTREACH_FILE(), { podcasts: [], emails: [], launches: [] }); + state.emails.push({ recipients: opts.recipients, subject: opts.subject, sentAt: new Date().toISOString(), count: 0, dryRun: true }); + await atomicWritePromote(OUTREACH_FILE(), state); return; } - let anyPosted = false; - for (const name of names) { - const pkg = `@profullstack/sh1pt-social-${name}`; - let adapter: SocialPlatform | undefined; - try { - adapter = await loadInstalledPackage>(pkg); - } catch { - // not installed - } - if (!adapter) { - console.log(kleur.dim(` ${name}: not installed — skipping`)); - continue; - } - - const adapterConfig = await getAdapterConfig(adapter.id); - if (!adapterConfig) { - console.log(kleur.yellow(` ${name}: not configured — run: sh1pt promote social setup --platform ${name}`)); - continue; - } - - const ctx = { - secret: (k: string) => process.env[k], - log: (m: string) => console.log(kleur.dim(` [${name}] ${m}`)), - dryRun: false, - }; - - try { - console.log(kleur.bold(` posting to ${adapter.label ?? name}…`)); - await adapter.connect(ctx, adapterConfig); - const result = await adapter.post(ctx, post, adapterConfig); - console.log(kleur.green(` ✓ ${adapter.label ?? name} · ${result.url}`)); - anyPosted = true; - } catch (err) { - console.error(kleur.red(` ✗ ${name}: ${err instanceof Error ? err.message : String(err)}`)); - } - } - - if (!anyPosted) { - console.log(kleur.yellow('\nno platforms posted — set up accounts with: sh1pt promote social setup')); - } - }); - -socialCmd - .command('metrics') - .description('Aggregated engagement across recent posts') - .option('--platform ') - .option('--json') - .action((opts: { platform?: string; json?: boolean }) => { - if (opts.json) { console.log(JSON.stringify({ posts: [], totals: {} }, null, 2)); return; } - console.log(kleur.dim('[stub] social metrics')); - }); - -// AI providers — generate ad copy / social bodies / taglines from a -// prompt. Distinct from `agents/` (which wraps installed CLI binaries -// like `claude` / `codex`); this is HTTP-API-based content generation -// keyed off provider API keys held in the vault. -const AI_PLATFORMS = [ - // Real integrations - 'claude', 'openai', 'qwen', 'gemini', - // BYOK stubs (OpenRouter-compatible providers — implementations land - // case-by-case; setup() collects the API key into the vault today) - 'ai21', 'aionlabs', 'akashml', 'alibaba-cloud', 'amazon-bedrock', 'arcee', - 'atlascloud', 'azure', 'baidu', 'baseten', 'cerebras', 'chutes', 'clarifai', - 'cloudflare', 'cohere', 'deepinfra', 'deepseek', 'featherless', 'fireworks', - 'friendli', 'gmicloud', 'google-vertex', 'groq', 'inception', 'inceptron', - 'infermatic', 'inflection', 'ionet', 'kimi', 'liquid', 'mancer', 'minimax', - 'mistral', 'moonshot', 'morph', 'nebius', 'nextbit', 'novita', - 'openinference', 'parasail', 'perceptron', 'perplexity', 'phala', 'reka', - 'relace', 'sambanova', 'siliconflow', 'stepfun', 'switchpoint', 'together', - 'venice', 'wandb', 'xai', 'xiaomi', 'zai', -]; - -const aiCmd = promoteCmd - .command('ai') - .description('Configure AI providers (Claude, OpenAI, Qwen, Gemini + 50+ BYOK stubs) used to draft ad copy and post bodies'); - -aiCmd - .command('setup') - .description("Connect AI providers — runs each provider adapter's setup (API key paste)") - .option('--platform ', 'e.g. claude openai (or ai-claude, ai-openai)') - .action(async (opts: { platform?: string[] }, cmd: Command) => { - const merged = cmd.optsWithGlobals() as { platform?: string[] }; - const requested = merged.platform ?? opts.platform; - let names = (requested ?? []).map(stripAiPrefix).filter(Boolean); - - if (names.length === 0) { - const res = await prompts({ - type: 'multiselect', - name: 'picks', - message: 'Which AI providers to set up?', - choices: AI_PLATFORMS.map((p) => ({ title: p, value: p })), - instructions: false, - hint: 'space to select, return to confirm', - }); - names = (res.picks as string[] | undefined) ?? []; - if (names.length === 0) { - console.log(kleur.dim('nothing selected — aborting.')); - return; - } - } - - const wanted = names.map((n) => `@profullstack/sh1pt-ai-${n}`); - try { - await ensureInstalled(wanted); - } catch (err) { - console.error(kleur.red(err instanceof Error ? err.message : String(err))); - process.exit(1); - } - - const ctx = makeCliSetupContext(); - for (const name of names) { - console.log(); - console.log(kleur.bold().underline(`ai: ${name}`)); - const pkg = `@profullstack/sh1pt-ai-${name}`; - const adapter = await loadInstalledPackage(pkg); - if (!adapter || typeof adapter !== 'object' || !('id' in adapter)) { - console.log(kleur.yellow(` failed to load ${pkg} after install — file an issue.`)); - continue; - } - await runSetup(adapter, ctx); - } - }); - -function stripAiPrefix(p: string): string { - return p.replace(/^ai-/, '').toLowerCase(); -} - -// Affiliate-network marketplaces — sister of `social` and `ai` but for -// performance partners. sh1pt user is typically the merchant (listing -// their product in the network so publishers can promote it for a -// commission), though many networks support both sides. -const AFFILIATE_NETWORKS = [ - 'cj', 'rakuten', 'shareasale', 'awin', 'impact', 'partnerstack', 'refersion', - 'amazon-associates', 'ebay-partner', 'clickbank', 'skimlinks', 'sovrn', - 'flexoffers', 'avangate', 'tradedoubler', 'jvzoo', 'digistore24', - 'tapfiliate', 'everflow', 'admitad', -]; - -const affiliatesCmd = promoteCmd - .command('affiliates') - .description('Affiliate network marketplaces — CJ, Rakuten, ShareASale, Awin, Impact, Amazon Associates, ClickBank, and more'); - -affiliatesCmd - .command('setup') - .description("Connect affiliate networks — runs each network adapter's setup (API key paste)") - .option('--network ', 'e.g. cj rakuten impact (or affiliate-cj, affiliate-impact)') - .action(async (opts: { network?: string[] }, cmd: Command) => { - const merged = cmd.optsWithGlobals() as { network?: string[]; platform?: string[] }; - const requested = merged.network ?? opts.network ?? merged.platform; - let names = (requested ?? []).map(stripAffiliatePrefix).filter(Boolean); - - if (names.length === 0) { - const res = await prompts({ - type: 'multiselect', - name: 'picks', - message: 'Which affiliate networks to set up?', - choices: AFFILIATE_NETWORKS.map((p) => ({ title: p, value: p })), - instructions: false, - hint: 'space to select, return to confirm', - }); - names = (res.picks as string[] | undefined) ?? []; - if (names.length === 0) { - console.log(kleur.dim('nothing selected — aborting.')); - return; - } - } - - const wanted = names.map((n) => `@profullstack/sh1pt-affiliate-${n}`); - try { - await ensureInstalled(wanted); - } catch (err) { - console.error(kleur.red(err instanceof Error ? err.message : String(err))); - process.exit(1); - } - - const ctx = makeCliSetupContext(); - for (const name of names) { - console.log(); - console.log(kleur.bold().underline(`affiliate: ${name}`)); - const pkg = `@profullstack/sh1pt-affiliate-${name}`; - const adapter = await loadInstalledPackage(pkg); - if (!adapter || typeof adapter !== 'object' || !('id' in adapter)) { - console.log(kleur.yellow(` failed to load ${pkg} after install — file an issue.`)); - continue; - } - await runSetup(adapter, ctx); - } - }); - -function stripAffiliatePrefix(p: string): string { - return p.replace(/^affiliate-/, '').toLowerCase(); -} - -affiliatesCmd - .command('list') - .description('List available affiliate network adapters') - .option('--json') - .action((opts: { json?: boolean }) => { - if (opts.json) { - console.log(JSON.stringify({ networks: AFFILIATE_NETWORKS }, null, 2)); + const resendKey = process.env.RESEND_API_KEY; + if (!resendKey) { + console.log(kleur.yellow('\nhint: set RESEND_API_KEY to send via Resend')); + console.log(kleur.dim(' export RESEND_API_KEY=re_...')); return; } - console.log(kleur.dim(`available: ${AFFILIATE_NETWORKS.join(', ')}`)); - }); -affiliatesCmd - .command('create-program') - .description('List your product as a merchant program in a connected network') - .requiredOption('--network ', 'e.g. cj, impact, partnerstack') - .requiredOption('--name ', 'program name') - .requiredOption('--destination ', 'where clicks should land') - .option('--commission ', 'numeric — 30 = 30% (percentage) or 30 = $30 (flat)', Number, 20) - .option('--commission-type ', 'percentage | flat | tiered', 'percentage') - .option('--cookie-days ', 'attribution window', Number, 30) - .option('--category ', 'saas | ecommerce | finance | other', 'saas') - .option('--currency ', 'ISO 4217 (for flat commissions)', 'USD') - .option('--dry-run') - .action((opts) => { - console.log(kleur.green(`[stub] affiliates create-program ${JSON.stringify(opts)}`)); - }); - -affiliatesCmd - .command('stats') - .description('Aggregated clicks / conversions / commissions across networks') - .option('--network ', 'filter to one network') - .option('--json') - .action((opts: { network?: string; json?: boolean }) => { - if (opts.json) { - console.log(JSON.stringify({ networks: [], totals: { publishers: 0, clicks: 0, conversions: 0, revenue: 0, commissionsPaid: 0 } }, null, 2)); - return; + let bodyTpl: string; + try { bodyTpl = await fs.readFile(opts.body, 'utf8'); } + catch { console.error(kleur.red(`cannot read body file: ${opts.body}`)); process.exit(1); } + + const fromAddr = opts.from ?? `hello@${process.env.RESEND_DOMAIN ?? 'your-domain.com'}`; + let sent = 0; + for (const addr of recipients) { + const payload = JSON.stringify({ from: fromAddr, to: [addr], subject: opts.subject, text: bodyTpl }); + const r = spawnSync('curl', ['-s', '-X', 'POST', 'https://api.resend.com/emails', + '-H', 'Content-Type: application/json', '-H', `Authorization: Bearer ${resendKey}`, + '-d', payload], { encoding: 'utf8' }); + if (r.status === 0) { sent++; process.stdout.write('.'); } + else process.stdout.write('x'); } - console.log(kleur.dim(`[stub] affiliates stats · network=${opts.network ?? 'all'}`)); - }); - -aiCmd - .command('list') - .description('List configured AI providers') - .option('--json') - .action((opts: { json?: boolean }) => { - if (opts.json) { console.log(JSON.stringify({ providers: AI_PLATFORMS }, null, 2)); return; } - console.log(kleur.dim(`available: ${AI_PLATFORMS.join(', ')}`)); - }); - -// Outreach umbrella — podcast booking, cold email, launch sites. -// Anything salesy we can automate beyond paid ads and public posts. -const outreachCmd = promoteCmd - .command('outreach') - .description('Podcasts, cold email, launch sites — anything salesy that scales'); - -outreachCmd - .command('podcasts') - .description('Discover relevant podcasts + send guest-pitch emails (Listen Notes + Resend)') - .option('--niche ', 'comma-separated topic list', 'ai,startups,devtools') - .option('--min-listeners ', 'minimum listener count filter', Number, 5000) - .option('--language ', '', 'en') - .option('--deck ', 'media kit / pitch deck') - .option('--dry-run') - .action((opts) => { - console.log(kleur.green(`[stub] podcast outreach ${JSON.stringify(opts)}`)); - }); + console.log(); + console.log(kleur.green(`\nsent ${sent}/${recipients.length} emails`)); -outreachCmd - .command('email') - .description('Cold email sequence via Resend — CAN-SPAM / CASL / GDPR compliance is your responsibility') - .requiredOption('--recipients ', 'CSV with email,name,company,...') - .requiredOption('--subject ') - .requiredOption('--body ', 'markdown/html body file with {{placeholders}}') - .option('--from ', 'must be a verified Resend domain') - .option('--rate ', 'max sends per hour', Number, 20) - .option('--dry-run') - .action((opts) => { - console.log(kleur.green(`[stub] email sequence ${JSON.stringify(opts)}`)); + const state = await readJsonPromote(OUTREACH_FILE(), { podcasts: [], emails: [], launches: [] }); + state.emails.push({ recipients: opts.recipients, subject: opts.subject, sentAt: new Date().toISOString(), count: sent, dryRun: false }); + await atomicWritePromote(OUTREACH_FILE(), state); }); outreachCmd @@ -920,17 +770,66 @@ outreachCmd .option('--schedule ', 'launch time; PH prefers 12:01 AM PST') .option('--tagline ') .option('--gallery ') - .action((opts) => { - console.log(kleur.green(`[stub] launch ${JSON.stringify(opts)}`)); + .action(async (opts: { site: string | string[]; schedule?: string; tagline?: string; gallery?: string[] }) => { + const sites = Array.isArray(opts.site) ? opts.site : [opts.site]; + const LAUNCH_URLS: Record = { + producthunt: 'https://www.producthunt.com/posts/new', + betalist: 'https://betalist.com/submit', + 'hn-show': 'https://news.ycombinator.com/submit', + indiehackers: 'https://www.indiehackers.com/post/new', + }; + + console.log(kleur.bold('\nlaunch checklist')); + for (const site of sites) { + console.log(`\n ${kleur.cyan(site.toUpperCase())}`); + const url = LAUNCH_URLS[site]; + if (url) console.log(` ${kleur.dim('→')} ${url}`); + if (site === 'producthunt') { + console.log(kleur.dim(' · best time: 12:01 AM PST on a Tuesday–Thursday')); + console.log(kleur.dim(' · need: tagline (≤60 chars), gallery (3+ images), description')); + console.log(kleur.dim(' · tip: hunter with 500+ followers multiplies reach')); + } else if (site === 'hn-show') { + console.log(kleur.dim(' · title must start with "Show HN:"')); + console.log(kleur.dim(' · post between 9–11 AM EST on weekdays')); + } + if (opts.tagline) console.log(` tagline: ${kleur.white(opts.tagline)}`); + if (opts.schedule) console.log(` schedule: ${kleur.yellow(opts.schedule)}`); + if (opts.gallery?.length) console.log(` gallery: ${opts.gallery.join(', ')}`); + } + + const state = await readJsonPromote(OUTREACH_FILE(), { podcasts: [], emails: [], launches: [] }); + state.launches.push({ sites, schedule: opts.schedule, tagline: opts.tagline, createdAt: new Date().toISOString() }); + await atomicWritePromote(OUTREACH_FILE(), state); + console.log(kleur.dim('\nlaunch saved — `sh1pt promote outreach status` to review')); }); outreachCmd .command('status') .description('Open podcast pitches, active email sequences, upcoming launch slots') .option('--json') - .action((opts: { json?: boolean }) => { - if (opts.json) { console.log(JSON.stringify({ podcasts: [], email: [], launches: [] }, null, 2)); return; } - console.log(kleur.dim('[stub] outreach status')); + .action(async (opts: { json?: boolean }) => { + const state = await readJsonPromote(OUTREACH_FILE(), { podcasts: [], emails: [], launches: [] }); + if (opts.json) { console.log(JSON.stringify(state, null, 2)); return; } + + if (!state.podcasts.length && !state.emails.length && !state.launches.length) { + console.log(kleur.dim('no outreach recorded — run `sh1pt promote outreach podcasts|email|launch`')); + return; + } + if (state.podcasts.length) { + console.log(kleur.bold('\npodcast pitches:')); + for (const p of state.podcasts) + console.log(` ${kleur.dim(p.pitchedAt.slice(0,10))} niche=${p.niche} min=${p.minListeners}${p.dryRun ? kleur.dim(' (dry)') : ''}`); + } + if (state.emails.length) { + console.log(kleur.bold('\nemail sequences:')); + for (const e of state.emails) + console.log(` ${kleur.dim(e.sentAt.slice(0,10))} ${kleur.cyan(e.subject)} sent=${e.count}${e.dryRun ? kleur.dim(' (dry)') : ''}`); + } + if (state.launches.length) { + console.log(kleur.bold('\nlaunch slots:')); + for (const l of state.launches) + console.log(` ${kleur.dim(l.createdAt.slice(0,10))} sites=${l.sites.join(',')}${l.schedule ? ` @${l.schedule}` : ''}${l.tagline ? ` "${l.tagline}"` : ''}`); + } }); // Communications bridge — relay messages between Slack / Discord / @@ -943,38 +842,146 @@ bridgeCmd .command('setup') .description('Connect a chat network (bot token / app password / nsec / IRC nick)') .option('--network ', 'e.g. bridge-discord bridge-matrix bridge-irc') - .action((opts: { network?: string[] }) => { - console.log(kleur.cyan(`[stub] bridge setup · ${opts.network?.join(', ') ?? 'all declared'}`)); + .action(async (opts: { network?: string[] }) => { + const networks = opts.network ?? ['bridge-discord', 'bridge-matrix', 'bridge-irc']; + const state = await readJsonPromote(BRIDGE_FILE(), { routes: [], networks: [] }); + + const SETUP_HINTS: Record = { + 'bridge-discord': ['DISCORD_BOT_TOKEN', '→ discord.com/developers → Bot → Token'], + 'bridge-slack': ['SLACK_BOT_TOKEN', '→ api.slack.com/apps → OAuth & Permissions'], + 'bridge-telegram': ['TELEGRAM_BOT_TOKEN', '→ @BotFather → /newbot'], + 'bridge-matrix': ['MATRIX_HOMESERVER,MATRIX_ACCESS_TOKEN', '→ Element Settings → Help & About → Access Token'], + 'bridge-irc': ['IRC_SERVER,IRC_NICK', '→ set IRC_SASL_PASS for authenticated nicks'], + 'bridge-signal': ['SIGNAL_PHONE', '→ install signal-cli and link your device'], + 'bridge-nostr': ['NOSTR_NSEC', '→ export your Nostr private key as nsec1…'], + 'bridge-mastodon': ['MASTODON_INSTANCE,MASTODON_ACCESS_TOKEN', '→ Preferences → Development → New Application'], + }; + + for (const net of networks) { + console.log(kleur.bold(`\n${net}`)); + const info = SETUP_HINTS[net]; + const envKeys = (info ? info[0] : `${net.replace('bridge-', '').toUpperCase()}_TOKEN`).split(','); + const hint = info ? info[1] : ''; + if (hint) console.log(kleur.dim(` ${hint}`)); + console.log(kleur.dim(` env: ${envKeys.join(', ')}`)); + const missing = envKeys.filter((k) => !process.env[k]); + if (missing.length === 0) { + console.log(kleur.green(' ✓ credentials found')); + if (!state.networks.includes(net)) state.networks.push(net); + } else { + console.log(kleur.yellow(` missing: ${missing.join(', ')}`)); + } + } + + await atomicWritePromote(BRIDGE_FILE(), state); + console.log(kleur.dim('\n`sh1pt promote bridge status` to review configured networks')); }); bridgeCmd .command('connect ') .description('Define a relay route. Format: ":". Repeatable destinations.') .option('--filter ', 'no-bots | no-pings | no-links | no-emojis') - .action((from: string, to: string[], opts: { filter?: string[] }) => { - console.log(kleur.green(`[stub] bridge connect ${from} → ${to.join(', ')}${opts.filter ? ` · filters=${opts.filter}` : ''}`)); + .action(async (from: string, to: string[], opts: { filter?: string[] }) => { + const state = await readJsonPromote(BRIDGE_FILE(), { routes: [], networks: [] }); + const filters = opts.filter ?? []; + const existing = state.routes.findIndex((r) => r.from === from && r.to.join(',') === to.join(',')); + if (existing >= 0) { + state.routes[existing].filters = filters; + console.log(kleur.yellow(`updated route: ${from} → ${to.join(', ')}`)); + } else { + state.routes.push({ from, to, filters, addedAt: new Date().toISOString() }); + console.log(kleur.green(`added route: ${from} → ${to.join(', ')}`)); + } + if (filters.length) console.log(kleur.dim(` filters: ${filters.join(', ')}`)); + await atomicWritePromote(BRIDGE_FILE(), state); + console.log(kleur.dim('run `sh1pt promote bridge start` to activate')); }); bridgeCmd .command('start') .description('Run the bridge daemon (persistent process — pair with deploy-fly for HA)') .option('--detach', 'background mode') - .action((opts: { detach?: boolean }) => { - console.log(kleur.green(`[stub] bridge start${opts.detach ? ' (detached)' : ' (foreground)'}`)); + .action(async (opts: { detach?: boolean }) => { + const state = await readJsonPromote(BRIDGE_FILE(), { routes: [], networks: [] }); + + if (state.pid) { + try { process.kill(state.pid, 0); console.log(kleur.yellow(`bridge already running (pid ${state.pid})`)); return; } + catch { state.pid = undefined; state.startedAt = undefined; } + } + + if (state.routes.length === 0) { + console.log(kleur.yellow('no routes defined — run `sh1pt promote bridge connect ` first')); + return; + } + + console.log(kleur.bold('\nbridge routes:')); + for (const r of state.routes) + console.log(` ${kleur.cyan(r.from)} → ${r.to.join(', ')}${r.filters.length ? kleur.dim(` (${r.filters.join(',')})`) : ''}`); + + if (opts.detach) { + console.log(kleur.yellow('\ndaemon mode — pair with a process manager:')); + console.log(kleur.dim(' pm2 start "sh1pt promote bridge start" --name sh1pt-bridge')); + console.log(kleur.dim(' fly deploy --app sh1pt-bridge')); + } else { + state.pid = process.pid; + state.startedAt = new Date().toISOString(); + await atomicWritePromote(BRIDGE_FILE(), state); + console.log(kleur.cyan('\nbridge running (foreground) — ctrl+c to stop')); + console.log(kleur.dim('(relay loop implementation: connect network adapters + event bus)')); + } }); bridgeCmd .command('stop') .description('Stop the bridge daemon') - .action(() => { console.log(kleur.yellow('[stub] bridge stop')); }); + .action(async () => { + const state = await readJsonPromote(BRIDGE_FILE(), { routes: [], networks: [] }); + if (!state.pid) { console.log(kleur.dim('bridge is not running')); return; } + try { + process.kill(state.pid, 'SIGTERM'); + console.log(kleur.yellow(`stopped bridge (pid ${state.pid})`)); + } catch { + console.log(kleur.dim(`pid ${state.pid} already gone`)); + } + state.pid = undefined; + state.startedAt = undefined; + await atomicWritePromote(BRIDGE_FILE(), state); + }); bridgeCmd .command('status') .description('Active routes + message counts + last-seen per network') .option('--json') - .action((opts: { json?: boolean }) => { - if (opts.json) { console.log(JSON.stringify({ routes: [], networks: [] }, null, 2)); return; } - console.log(kleur.dim('[stub] bridge status')); + .action(async (opts: { json?: boolean }) => { + const state = await readJsonPromote(BRIDGE_FILE(), { routes: [], networks: [] }); + if (opts.json) { console.log(JSON.stringify(state, null, 2)); return; } + + if (!state.routes.length && !state.networks.length) { + console.log(kleur.dim('no bridge configured — run `sh1pt promote bridge setup` then `bridge connect`')); + return; + } + + if (state.networks.length) { + console.log(kleur.bold('\nconfigured networks:')); + for (const n of state.networks) console.log(` ${kleur.cyan(n)}`); + } + + if (state.routes.length) { + console.log(kleur.bold('\nroutes:')); + for (const r of state.routes) + console.log(` ${kleur.cyan(r.from)} → ${r.to.join(', ')}${r.filters.length ? kleur.dim(` [${r.filters.join(',')}]`) : ''}`); + } + + if (state.pid) { + try { + process.kill(state.pid, 0); + console.log(kleur.green(`\ndaemon running (pid ${state.pid})${state.startedAt ? ` since ${state.startedAt.slice(0, 16)}` : ''}`)); + } catch { + console.log(kleur.yellow(`\ndaemon crashed (last pid ${state.pid}) — run \`bridge start\` to restart`)); + } + } else { + console.log(kleur.dim('\ndaemon not running — `sh1pt promote bridge start` to begin')); + } }); // Document generation — pitch decks, one-pagers, press kits, memos. @@ -992,17 +999,93 @@ docsCmd .option('--provider ', 'docs-marp | docs-gslides | docs-pandoc | docs-lumin', 'docs-marp') .option('--out ', 'where to write the result', './.sh1pt/docs/') .option('--upload-to-lumin', 'after generation, upload the PDF to LuminPDF for a shareable viewer link') - .action((opts) => { - console.log(kleur.green(`[stub] docs generate ${JSON.stringify(opts)}`)); + .action(async (opts: { kind: string; format: string; markdown: string; template?: string; provider: string; out: string; uploadToLumin?: boolean }) => { + const outDir = opts.out.replace(/\/?$/, '/'); + const slug = `${opts.kind}-${Date.now()}`; + const outPath = `${outDir}${slug}.${opts.format}`; + + console.log(kleur.bold('\ndocs generate')); + console.log(` kind: ${kleur.cyan(opts.kind)}`); + console.log(` format: ${kleur.cyan(opts.format)}`); + console.log(` provider: ${kleur.cyan(opts.provider)}`); + console.log(` markdown: ${kleur.cyan(opts.markdown)}`); + console.log(` out: ${kleur.cyan(outPath)}`); + if (opts.template) console.log(` template: ${kleur.cyan(opts.template)}`); + + await fs.mkdir(outDir, { recursive: true }); + + if (opts.provider === 'docs-marp') { + const marpArgs = [opts.markdown, '--output', outPath]; + if (opts.template) marpArgs.push('--theme', opts.template); + if (opts.format === 'pdf') marpArgs.push('--pdf'); + else if (opts.format === 'pptx') marpArgs.push('--pptx'); + console.log(kleur.dim('\nrunning marp…')); + const r = spawnSync('marp', marpArgs, { encoding: 'utf8', stdio: 'inherit' }); + if (r.error) { console.log(kleur.yellow('marp not found — install: npm install -g @marp-team/marp-cli')); return; } + if (r.status !== 0) { console.error(kleur.red('marp failed')); process.exit(1); } + console.log(kleur.green(`\ngenerated: ${outPath}`)); + } else if (opts.provider === 'docs-pandoc') { + const pandocArgs = [opts.markdown, '-o', outPath]; + if (opts.template) pandocArgs.push('--reference-doc', opts.template); + console.log(kleur.dim('\nrunning pandoc…')); + const r = spawnSync('pandoc', pandocArgs, { encoding: 'utf8', stdio: 'inherit' }); + if (r.error) { console.log(kleur.yellow('pandoc not found — https://pandoc.org/installing.html')); return; } + if (r.status !== 0) { console.error(kleur.red('pandoc failed')); process.exit(1); } + console.log(kleur.green(`\ngenerated: ${outPath}`)); + } else if (opts.provider === 'docs-gslides') { + if (!process.env.GOOGLE_SERVICE_ACCOUNT_JSON) { + console.log(kleur.yellow('set GOOGLE_SERVICE_ACCOUNT_JSON to use the Google Slides provider')); + return; + } + console.log(kleur.dim('Google Slides export — requires Slides API integration (not yet implemented)')); + } else if (opts.provider === 'docs-lumin') { + if (!process.env.LUMIN_API_KEY) { + console.log(kleur.yellow('set LUMIN_API_KEY to use the LuminPDF provider')); + return; + } + console.log(kleur.dim('LuminPDF upload — requires source PDF; generate with docs-marp first')); + } else { + console.log(kleur.yellow(`unknown provider: ${opts.provider}`)); + return; + } + + if (opts.uploadToLumin) { + const luminKey = process.env.LUMIN_API_KEY; + if (!luminKey) { + console.log(kleur.yellow('LUMIN_API_KEY not set — skipping upload')); + } else { + console.log(kleur.dim('\nuploading to LuminPDF…')); + const r = spawnSync('curl', ['-s', '-X', 'POST', 'https://api.luminpdf.com/v1/documents', + '-H', `Authorization: Bearer ${luminKey}`, '-F', `file=@${outPath}`], { encoding: 'utf8' }); + if (r.status === 0) { + try { + const data = JSON.parse(r.stdout) as { url?: string }; + if (data.url) console.log(kleur.green(` shareable: ${data.url}`)); + else console.log(kleur.dim(` ${r.stdout.slice(0, 200)}`)); + } catch { console.log(kleur.dim(` ${r.stdout.slice(0, 200)}`)); } + } + } + } + + const docs = await readJsonPromote(DOCS_FILE(), []); + docs.push({ kind: opts.kind, format: opts.format, provider: opts.provider, outPath, generatedAt: new Date().toISOString() }); + await atomicWritePromote(DOCS_FILE(), docs); }); docsCmd .command('list') .description('Recently generated docs') .option('--json') - .action((opts: { json?: boolean }) => { - if (opts.json) { console.log(JSON.stringify({ docs: [] }, null, 2)); return; } - console.log(kleur.dim('[stub] docs list')); + .action(async (opts: { json?: boolean }) => { + const docs = await readJsonPromote(DOCS_FILE(), []); + if (opts.json) { console.log(JSON.stringify({ docs }, null, 2)); return; } + if (!docs.length) { + console.log(kleur.dim('no docs generated yet — run `sh1pt promote docs generate`')); + return; + } + console.log(kleur.bold('\ngenerated docs:')); + for (const d of docs) + console.log(` ${kleur.dim(d.generatedAt.slice(0, 10))} ${kleur.cyan(d.kind)} ${d.format} via ${d.provider} ${kleur.dim(d.outPath)}`); }); // Publish to package registries. Promote because publishing IS