@sellable/install
Version:
One-command installer for Sellable MCP in Claude Code, Codex, and Hermes
173 lines (169 loc) • 7.18 kB
JavaScript
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" };
}
}