UNPKG

@sellable/install

Version:

One-command installer for Sellable MCP in Claude Code, Codex, and Hermes

173 lines (169 loc) • 7.18 kB
import { execFile } from "node:child_process"; import { lstatSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { promisify } from "node:util"; export function firstCampaignWelcome(botUserId) { if (!/^[UW][A-Z0-9]+$/.test(botUserId ?? "") || /[\r\n\0]/.test(botUserId)) throw new Error("first_welcome_identity_rejected"); return [ // Hermes converts standard Markdown to Slack mrkdwn. Single stars here // became italics in the 2026-09-19 whole-image welcome receipt. "**I'm live and ready to run your LinkedIn outbound.**", // One question, and what the answer is for. The onboarding skill starts // from a profile URL in this thread, so the ask here must match it. "**First question: what's your LinkedIn profile URL?** Reply in this thread. I'll use it to research you and your company, then bring back a first campaign for you to review.", // A reply in a thread this bot started needs no mention: the gateway // wakes on a bot-authored thread root. A new conversation does need one. `**How to talk to me:** reply in a thread I'm in, or tag <@${botUserId}> in this channel. To have me listen in other channels, add them in the Sellable app under Agent.`, "Nothing sends on LinkedIn until you approve the launch.", ].join("\n\n"); } // Use the native one-shot scheduler and gateway delivery queue. Do not send to // Slack from this script or start an LLM merely to print the welcome. The intent // lives under cron/ so native snapshots retain it even after one-shot pruning. const PREPARE = String.raw` import fcntl,json,os,sys,tempfile from datetime import datetime,timedelta,timezone from pathlib import Path from cron.jobs import create_job,load_jobs v=json.loads(sys.argv[1]) root=Path(os.environ['HERMES_HOME']) cron=root/'cron' cron.mkdir(mode=0o700,exist_ok=True) name='sellable-first-welcome' marker=cron/(name+'.json') scope={k:v[k] for k in ['workspaceId','channelId','botUserId']} def save(path,value): fd,tmp=tempfile.mkstemp(dir=path.parent,prefix='.'+path.name) try: with os.fdopen(fd,'w') as f: f.write(value); f.flush(); os.fsync(f.fileno()) os.replace(tmp,path) directory=os.open(path.parent,os.O_RDONLY|os.O_DIRECTORY) try: os.fsync(directory) finally: os.close(directory) finally: if os.path.exists(tmp): os.unlink(tmp) def result(**value): print(json.dumps(value)) with os.fdopen(os.open(cron/(name+'.lock'),os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600),'a') as lock: fcntl.flock(lock,fcntl.LOCK_EX) if marker.is_symlink(): raise ValueError('unsafe welcome marker') jobs=load_jobs() state=json.loads(marker.read_text()) if marker.exists() else None if state is None: existing=not v['newAgent'] or any(j.get('name')=='sellable-first-campaign-onboarding' for j in jobs) state={'schemaVersion':1,**scope,'status':'EXISTING' if existing else 'PENDING'} save(marker,json.dumps(state)) if any(state.get(k)!=val for k,val in scope.items()): result(ok=False,code='first_welcome_scope_mismatch') elif state['status'] in ['EXISTING','QUEUED'] or not v['ready']: result(ok=True,status=state['status'],jobId=state.get('jobId')) elif state['status']=='DISPATCHING': # A crash may have happened between native creation and receipt write. # Never interpret uncertainty as permission to send another welcome. result(ok=False,code='first_welcome_dispatch_uncertain') elif state['status']=='PENDING': if any(j.get('name')==name for j in jobs): result(ok=False,code='first_welcome_inventory_rejected') else: scripts=root/'scripts' if scripts.is_symlink(): raise ValueError('unsafe scripts directory') scripts.mkdir(mode=0o700,exist_ok=True) script=name+'.py' save(scripts/script,'print('+repr(v['message'])+')\n') state['status']='DISPATCHING' save(marker,json.dumps(state)) # Slack's bot-authored parent already supports normal thread replies. # Native attach_to_session would prepend a separate "Hermes — job" # seed message and bury the welcome in its thread. job=create_job(prompt='',name=name,schedule=(datetime.now(timezone.utc)+timedelta(seconds=1)).isoformat(),repeat=1, script=script,no_agent=True,deliver='slack:'+v['channelId'],failure_deliver='local', origin={'platform':'slack','chat_id':v['channelId']},attach_to_session=False) state.update(status='QUEUED',jobId=job['id']) save(marker,json.dumps(state)) result(ok=True,status='QUEUED',jobId=job['id']) else: result(ok=False,code='first_welcome_state_rejected') `; /** Prepare before installing the daily cron; enqueue only after ready/complete. * QUEUED means native delivery owns the job, NOT that Slack delivery succeeded. * Failure must never turn a healthy Agent into failed web onboarding. */ export async function ensureHermesFirstWelcome({ profileId, profileRoot, hermesRoot, workspaceId, channelId, botUserId, newAgent = false, ready = false, hermesPython = "/opt/hermes/.venv/bin/python", runChild, }) { try { if ( !/^[a-zA-Z0-9_-]+$/.test(profileId ?? "") || !/^[a-zA-Z0-9_-]+$/.test(workspaceId ?? "") || !/^C[A-Z0-9]+$/.test(channelId ?? "") || /[\r\n\0]/.test(`${profileId}${workspaceId}${channelId}`) || !isAbsolute(hermesRoot) || resolve(profileRoot) !== join(resolve(hermesRoot), "profiles", profileId) ) return { ok: false, code: "first_welcome_identity_rejected" }; for (const path of [ hermesRoot, join(hermesRoot, "profiles"), profileRoot, join(profileRoot, "cron"), ]) { try { if (!lstatSync(path).isDirectory()) return { ok: false, code: "first_welcome_path_rejected" }; } catch (e) { if (e.code !== "ENOENT" || path !== join(profileRoot, "cron")) throw e; } } const message = firstCampaignWelcome(botUserId); const input = { command: hermesPython, args: [ "-c", PREPARE, JSON.stringify({ workspaceId, channelId, botUserId, newAgent: newAgent === true, ready: ready === true, message, }), ], env: { ...process.env, HOME: hermesRoot, HERMES_HOME: profileRoot, HERMES_PROFILE: profileId, NO_COLOR: "1", }, timeoutMs: 30_000, shell: false, }; const child = runChild ? await runChild(input) : { exitCode: 0, ...(await promisify(execFile)(input.command, input.args, { env: input.env, timeout: input.timeoutMs, maxBuffer: 64 * 1024, })), }; if (child.exitCode !== 0 || child.timedOut) throw new Error("native child failed"); return JSON.parse(child.stdout.trim()); } catch { return { ok: false, code: "first_welcome_prepare_failed" }; } }