social-autoposter 1.6.85 → 1.6.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +4 -3
- package/mcp/dist/index.js +84 -11
- package/mcp/dist/repo.js +0 -63
- package/mcp/dist/telemetry.js +1 -127
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/add_capstacker_config.py +171 -0
- package/scripts/harness_overlay.py +0 -340
- package/scripts/release-mcpb.sh +109 -20
- package/skill/run-overlay-watch.sh +4 -3
package/bin/cli.js
CHANGED
|
@@ -591,9 +591,10 @@ function generatePlists() {
|
|
|
591
591
|
stderrLog: `${DEST}/skill/logs/launchd-self-update-stderr.log`,
|
|
592
592
|
},
|
|
593
593
|
{
|
|
594
|
-
// On-screen overlay watcher supervisor. The overlay (harness status banner
|
|
595
|
-
//
|
|
596
|
-
//
|
|
594
|
+
// On-screen overlay watcher supervisor. The overlay (harness status banner;
|
|
595
|
+
// the interactive draft sidebar is OFF by default as of 2026-06-23, opt back
|
|
596
|
+
// in with SAPS_SIDEBAR=1) only renders WHILE harness_overlay.py watch runs.
|
|
597
|
+
// The supervisor is idempotent (pgrep guard), so a 60s StartInterval
|
|
597
598
|
// is a no-op while the watcher is up and re-spawns it within a minute if it
|
|
598
599
|
// ever dies. RunAtLoad starts it right after install. This is what makes the
|
|
599
600
|
// overlay appear on headless / remote installs (Lane A); the MCP covers the
|
package/mcp/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from
|
|
|
22
22
|
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, ensurePipelineCurrent, } from "./runtime.js";
|
|
23
23
|
import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
|
|
24
24
|
import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
|
|
25
|
-
import { initSentry, sendHeartbeat, captureError, flushSentry
|
|
25
|
+
import { initSentry, sendHeartbeat, captureError, flushSentry } from "./telemetry.js";
|
|
26
26
|
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, getUiCapability, } from "@modelcontextprotocol/ext-apps/server";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import http from "node:http";
|
|
@@ -346,7 +346,8 @@ function cycleProgressMessage(line) {
|
|
|
346
346
|
return null;
|
|
347
347
|
}
|
|
348
348
|
// Start the twitter-harness on-screen overlay watcher if it isn't already up.
|
|
349
|
-
// The overlay (status banner
|
|
349
|
+
// The overlay (status banner; the interactive draft sidebar is OFF by default as
|
|
350
|
+
// of 2026-06-23, opt back in with SAPS_SIDEBAR=1) only renders WHILE
|
|
350
351
|
// `harness_overlay.py watch` runs. The supervisor script is idempotent (pgrep
|
|
351
352
|
// guard), so calling this on every draft_cycle / autopilot-enable / show-browser
|
|
352
353
|
// is safe: it spawns at most one detached watcher and is a fast no-op otherwise.
|
|
@@ -616,7 +617,6 @@ async function postApproved(batchId, plan) {
|
|
|
616
617
|
});
|
|
617
618
|
void flushSentry(2000);
|
|
618
619
|
}
|
|
619
|
-
void flushLogs();
|
|
620
620
|
return {
|
|
621
621
|
attempted: approved.length,
|
|
622
622
|
posted: realPosted,
|
|
@@ -1470,7 +1470,7 @@ async function autopilotLoaded() {
|
|
|
1470
1470
|
// version is older than what this build ships. The host reads the file fresh on
|
|
1471
1471
|
// each run, so the next fire picks up the new prompt — no host tool, no user
|
|
1472
1472
|
// action. Bump AUTOPILOT_PROMPT_VERSION whenever the prompt below changes.
|
|
1473
|
-
const AUTOPILOT_PROMPT_VERSION =
|
|
1473
|
+
const AUTOPILOT_PROMPT_VERSION = 2;
|
|
1474
1474
|
const AUTOPILOT_PROMPT_MARKER = "saps_autopilot_prompt_version";
|
|
1475
1475
|
function autopilotSkillPath() {
|
|
1476
1476
|
const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
@@ -1484,7 +1484,11 @@ function autopilotSkillMd(project) {
|
|
|
1484
1484
|
`You are running the Social Autoposter draft-only autopilot for the project "${project}". ` +
|
|
1485
1485
|
`Run ONE cycle, then stop. DRAFT-ONLY: you must NEVER post to X/Twitter — you only queue ` +
|
|
1486
1486
|
`drafts for the user's approval. Use ONLY two tools — scan_candidates and submit_drafts — ` +
|
|
1487
|
-
`and IMPROVISE NOTHING ELSE
|
|
1487
|
+
`and IMPROVISE NOTHING ELSE. Specifically: NEVER run Bash, Read, Write, Edit, python, or any ` +
|
|
1488
|
+
`shell/file tool, for ANY reason — not to debug, inspect a tool result, verify an outcome, or ` +
|
|
1489
|
+
`investigate something surprising. This run is unattended: any tool that is not pre-approved ` +
|
|
1490
|
+
`STALLS the whole pipeline waiting on a human who is not there, so reaching for one is never ` +
|
|
1491
|
+
`worth it. If something looks off, report it in one line and stop.`,
|
|
1488
1492
|
``,
|
|
1489
1493
|
`Steps:`,
|
|
1490
1494
|
`1. Call scan_candidates with project "${project}". It long-polls: if it returns a "Scan in ` +
|
|
@@ -1501,6 +1505,12 @@ function autopilotSkillMd(project) {
|
|
|
1501
1505
|
`4. Call submit_drafts with the batch_id from scan_candidates and a drafts array of ` +
|
|
1502
1506
|
`{candidate_id, reply_text}. This queues them for the menu-bar approval UI. Do NOT call ` +
|
|
1503
1507
|
`post_drafts — posting is the user's decision.`,
|
|
1508
|
+
`5. You are now DONE: report in ONE short line how many drafts you queued, then stop. ` +
|
|
1509
|
+
`"Queued 0 new draft(s)" is a NORMAL, expected result — it means the threads were already ` +
|
|
1510
|
+
`queued in an earlier cycle (dedup), NOT a failure. Treat ANY count, including zero, as ` +
|
|
1511
|
+
`success. NEVER re-read, parse, or inspect the submit_drafts result; NEVER open files under ` +
|
|
1512
|
+
`tool-results/ or anywhere else; NEVER run Bash/Read/python to confirm what happened. A ` +
|
|
1513
|
+
`zero or surprising count is not a reason to investigate — just report it and stop.`,
|
|
1504
1514
|
``,
|
|
1505
1515
|
`HARD GUARD: if scan_candidates is NOT available (ToolSearch returns no matching tool — can ` +
|
|
1506
1516
|
`happen when a run spawns before the extension's MCP server has reconnected), STOP immediately ` +
|
|
@@ -1552,6 +1562,71 @@ function ensureAutopilotPromptCurrent() {
|
|
|
1552
1562
|
console.error(`[autopilot] ensureAutopilotPromptCurrent error: ${e?.message || e}`);
|
|
1553
1563
|
}
|
|
1554
1564
|
}
|
|
1565
|
+
// ---- Pre-approve the autopilot's own tools in ~/.claude/settings.json --------
|
|
1566
|
+
// A Desktop scheduled task created via create_scheduled_task defaults to "Ask"
|
|
1567
|
+
// permission mode, and per the Desktop docs an un-pre-approved tool STALLS the
|
|
1568
|
+
// run until a human approves it. Allow rules from ~/.claude/settings.json DO
|
|
1569
|
+
// apply to scheduled-task sessions, so we ship pre-approval for THIS server's
|
|
1570
|
+
// tools (the only ones the autopilot is allowed to use) into that file on boot.
|
|
1571
|
+
// This makes the legitimate scan -> submit path never stall, independent of
|
|
1572
|
+
// whether anyone clicked "Always allow" during onboarding. ALLOW-only by design:
|
|
1573
|
+
// it pre-approves our tools; it does NOT (and on this lane cannot) restrict any
|
|
1574
|
+
// others — that is the prompt's job. Merge-in-place: read, add only missing
|
|
1575
|
+
// entries, write back; never overwrite a user's settings on a parse error.
|
|
1576
|
+
//
|
|
1577
|
+
// The tool id is mcp__<server>__<tool>. A .mcpb install registers this server
|
|
1578
|
+
// under the manifest name ("social-autoposter"); we also list the protocol name
|
|
1579
|
+
// ("S4L") so pre-approval matches however the host loaded it. Extra entries that
|
|
1580
|
+
// don't match the live namespace are harmless no-ops.
|
|
1581
|
+
const AUTOPILOT_ALLOWED_TOOLS = [
|
|
1582
|
+
"mcp__social-autoposter__scan_candidates",
|
|
1583
|
+
"mcp__social-autoposter__submit_drafts",
|
|
1584
|
+
"mcp__S4L__scan_candidates",
|
|
1585
|
+
"mcp__S4L__submit_drafts",
|
|
1586
|
+
];
|
|
1587
|
+
function ensureAutopilotToolsAllowed() {
|
|
1588
|
+
try {
|
|
1589
|
+
// Only touch settings.json for installs that actually have the autopilot
|
|
1590
|
+
// scheduled — mirrors the prompt-refresh gate, so we never edit a user's
|
|
1591
|
+
// global settings on a machine that doesn't use the scheduled task.
|
|
1592
|
+
if (!fs.existsSync(autopilotSkillPath()))
|
|
1593
|
+
return;
|
|
1594
|
+
const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
1595
|
+
const settingsPath = path.join(cfg, "settings.json");
|
|
1596
|
+
let settings = {};
|
|
1597
|
+
if (fs.existsSync(settingsPath)) {
|
|
1598
|
+
try {
|
|
1599
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8")) || {};
|
|
1600
|
+
}
|
|
1601
|
+
catch (e) {
|
|
1602
|
+
// Malformed user settings: do NOT clobber — log and bail.
|
|
1603
|
+
console.error(`[autopilot] settings.json unparseable; skipping tool pre-approval: ${e?.message || e}`);
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
if (typeof settings !== "object" || Array.isArray(settings))
|
|
1608
|
+
return;
|
|
1609
|
+
const perms = (settings.permissions ??= {});
|
|
1610
|
+
if (typeof perms !== "object" || Array.isArray(perms))
|
|
1611
|
+
return;
|
|
1612
|
+
const allow = Array.isArray(perms.allow) ? perms.allow : (perms.allow = []);
|
|
1613
|
+
let added = 0;
|
|
1614
|
+
for (const t of AUTOPILOT_ALLOWED_TOOLS) {
|
|
1615
|
+
if (!allow.includes(t)) {
|
|
1616
|
+
allow.push(t);
|
|
1617
|
+
added++;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
if (added === 0)
|
|
1621
|
+
return;
|
|
1622
|
+
fs.mkdirSync(cfg, { recursive: true });
|
|
1623
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
1624
|
+
console.error(`[autopilot] pre-approved ${added} autopilot tool(s) in ${settingsPath} (allow-only)`);
|
|
1625
|
+
}
|
|
1626
|
+
catch (e) {
|
|
1627
|
+
console.error(`[autopilot] ensureAutopilotToolsAllowed error: ${e?.message || e}`);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1555
1630
|
// Assemble everything the panel needs in one shot (projects + X + autopilot +
|
|
1556
1631
|
// version). Resilient: any probe that throws degrades to a safe default rather
|
|
1557
1632
|
// than failing the whole snapshot.
|
|
@@ -2384,11 +2459,6 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
|
|
|
2384
2459
|
}));
|
|
2385
2460
|
async function main() {
|
|
2386
2461
|
initSentry();
|
|
2387
|
-
// Tee the verbatim stdout/stderr of every pipeline subprocess to the s4l
|
|
2388
|
-
// backend so we can troubleshoot/rescue any user scenario (silent stalls,
|
|
2389
|
-
// partial onboarding) without asking them to ship a log file. Best-effort;
|
|
2390
|
-
// disabled with SAPS_LOG_STREAM=0.
|
|
2391
|
-
startLogStreaming();
|
|
2392
2462
|
// A plugin UPDATE refreshes this server (dist/) but not the materialized
|
|
2393
2463
|
// pipeline. Re-extract the bundled pipeline.tgz when it's newer than what's on
|
|
2394
2464
|
// disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
|
|
@@ -2399,6 +2469,10 @@ async function main() {
|
|
|
2399
2469
|
// newer prompt version, so behavior changes (no-improvise / voice-inline /
|
|
2400
2470
|
// stop-cleanly) reach an already-scheduled task on its next fire. Best-effort.
|
|
2401
2471
|
ensureAutopilotPromptCurrent();
|
|
2472
|
+
// Pre-approve THIS server's tools in ~/.claude/settings.json so the unattended
|
|
2473
|
+
// scan -> submit path never stalls on an "Ask mode" permission prompt (see the
|
|
2474
|
+
// helper's note). Best-effort, allow-only, gated on the task existing.
|
|
2475
|
+
ensureAutopilotToolsAllowed();
|
|
2402
2476
|
const transport = new StdioServerTransport();
|
|
2403
2477
|
await server.connect(transport);
|
|
2404
2478
|
console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);
|
|
@@ -2439,7 +2513,6 @@ async function main() {
|
|
|
2439
2513
|
main().catch(async (err) => {
|
|
2440
2514
|
console.error("[social-autoposter-mcp] fatal:", err);
|
|
2441
2515
|
captureError(err, { component: "main" });
|
|
2442
|
-
await flushLogs();
|
|
2443
2516
|
await flushSentry();
|
|
2444
2517
|
process.exit(1);
|
|
2445
2518
|
});
|
package/mcp/dist/repo.js
CHANGED
|
@@ -30,27 +30,6 @@ export function repoDir() {
|
|
|
30
30
|
// "No drafts in batch ...". Default to /tmp to match the script; allow an explicit
|
|
31
31
|
// override for non-standard installs.
|
|
32
32
|
export const TMP_DIR = process.env.SAPS_TMP_DIR || "/tmp";
|
|
33
|
-
let lineSink = null;
|
|
34
|
-
export function setLineSink(fn) {
|
|
35
|
-
lineSink = fn;
|
|
36
|
-
}
|
|
37
|
-
// Derive a short, stable context label for a spawned command so log lines can
|
|
38
|
-
// be grouped by which script produced them (e.g. "scripts/seed_search_topics.py",
|
|
39
|
-
// "skill/run-twitter-cycle.sh"). Best-effort; never throws.
|
|
40
|
-
function deriveContext(cmd, args) {
|
|
41
|
-
try {
|
|
42
|
-
const base = cmd.split("/").pop() || cmd;
|
|
43
|
-
if (/python/i.test(base) || base === "bash" || base === "sh" || base === "node") {
|
|
44
|
-
const first = args.find((a) => !a.startsWith("-"));
|
|
45
|
-
if (first)
|
|
46
|
-
return first;
|
|
47
|
-
}
|
|
48
|
-
return [base, args[0] ?? ""].join(" ").trim();
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return cmd;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
33
|
// Spawn a process inside the repo, inheriting the repo env (API base + keys
|
|
55
34
|
// come from the install's environment / .env loaded by the scripts themselves).
|
|
56
35
|
//
|
|
@@ -94,29 +73,6 @@ export function run(cmd, args, opts = {}) {
|
|
|
94
73
|
}
|
|
95
74
|
return buf;
|
|
96
75
|
};
|
|
97
|
-
// Parallel whole-line splitter that tees to the telemetry sink (if any),
|
|
98
|
-
// kept separate from the onLine pump so neither path can affect the other.
|
|
99
|
-
const logCtx = opts.logContext || deriveContext(cmd, args);
|
|
100
|
-
let sinkOutBuf = "";
|
|
101
|
-
let sinkErrBuf = "";
|
|
102
|
-
const sinkPump = (chunk, which, buf) => {
|
|
103
|
-
const sink = lineSink;
|
|
104
|
-
if (!sink)
|
|
105
|
-
return buf;
|
|
106
|
-
buf += chunk;
|
|
107
|
-
let nl;
|
|
108
|
-
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
109
|
-
const line = buf.slice(0, nl);
|
|
110
|
-
buf = buf.slice(nl + 1);
|
|
111
|
-
try {
|
|
112
|
-
sink(line, which, logCtx);
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
/* the telemetry sink must never break the wrapped command */
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return buf;
|
|
119
|
-
};
|
|
120
76
|
let timer;
|
|
121
77
|
if (opts.timeoutMs) {
|
|
122
78
|
timer = setTimeout(() => {
|
|
@@ -127,13 +83,11 @@ export function run(cmd, args, opts = {}) {
|
|
|
127
83
|
const s = d.toString();
|
|
128
84
|
stdout += s;
|
|
129
85
|
outBuf = pump(s, "stdout", outBuf);
|
|
130
|
-
sinkOutBuf = sinkPump(s, "stdout", sinkOutBuf);
|
|
131
86
|
});
|
|
132
87
|
child.stderr.on("data", (d) => {
|
|
133
88
|
const s = d.toString();
|
|
134
89
|
stderr += s;
|
|
135
90
|
errBuf = pump(s, "stderr", errBuf);
|
|
136
|
-
sinkErrBuf = sinkPump(s, "stderr", sinkErrBuf);
|
|
137
91
|
});
|
|
138
92
|
child.on("close", (code) => {
|
|
139
93
|
if (timer)
|
|
@@ -155,23 +109,6 @@ export function run(cmd, args, opts = {}) {
|
|
|
155
109
|
/* ignore */
|
|
156
110
|
}
|
|
157
111
|
}
|
|
158
|
-
const sink = lineSink;
|
|
159
|
-
if (sink) {
|
|
160
|
-
if (sinkOutBuf)
|
|
161
|
-
try {
|
|
162
|
-
sink(sinkOutBuf, "stdout", logCtx);
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
/* ignore */
|
|
166
|
-
}
|
|
167
|
-
if (sinkErrBuf)
|
|
168
|
-
try {
|
|
169
|
-
sink(sinkErrBuf, "stderr", logCtx);
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
/* ignore */
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
112
|
resolve({ code: code ?? -1, stdout, stderr });
|
|
176
113
|
});
|
|
177
114
|
child.on("error", (err) => {
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import * as Sentry from "@sentry/node";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import fs from "node:fs";
|
|
12
|
-
import { repoDir, runPython
|
|
12
|
+
import { repoDir, runPython } from "./repo.js";
|
|
13
13
|
import { VERSION } from "./version.js";
|
|
14
14
|
// Sentry DSN is a client-side identifier (safe to embed, same posture as Fazm's
|
|
15
15
|
// hardcoded Swift DSN). Overridable via env for dev. Empty -> Sentry disabled.
|
|
@@ -102,129 +102,3 @@ export async function sendHeartbeat(reason) {
|
|
|
102
102
|
console.error("[social-autoposter-mcp] heartbeat failed:", err?.message || err);
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
// ---- Raw subprocess log streaming ------------------------------------------
|
|
106
|
-
// Tees the verbatim stdout/stderr of every pipeline subprocess (via the
|
|
107
|
-
// repo.ts run() boundary) to the s4l backend, so we can troubleshoot and
|
|
108
|
-
// rescue any user scenario without asking them to ship a log file. Lines are
|
|
109
|
-
// buffered in memory and flushed in small batches under the same X-Installation
|
|
110
|
-
// identity the heartbeat uses. Best-effort: this NEVER throws into the server,
|
|
111
|
-
// never blocks the child's I/O, and drops on overflow rather than growing
|
|
112
|
-
// unbounded. Disable with SAPS_LOG_STREAM=0.
|
|
113
|
-
const LOG_STREAM_ENABLED = process.env.SAPS_LOG_STREAM !== "0";
|
|
114
|
-
const LOG_MAX_LINE_LEN = 8192; // mirror the backend cap
|
|
115
|
-
const LOG_MAX_BUFFER = 1000; // drop oldest beyond this (overflow protection)
|
|
116
|
-
const LOG_FLUSH_BATCH = 100; // flush eagerly once we have this many lines
|
|
117
|
-
const LOG_MAX_PER_POST = 200; // backend accepts 1-200 per request
|
|
118
|
-
const LOG_FLUSH_MS = 3000; // otherwise flush on this cadence
|
|
119
|
-
const logBuffer = [];
|
|
120
|
-
let logDropped = 0; // count of lines dropped on overflow (surfaced periodically)
|
|
121
|
-
let logFlushing = false;
|
|
122
|
-
let logTimer;
|
|
123
|
-
let cachedInstallHeader = null;
|
|
124
|
-
let logStreamingStarted = false;
|
|
125
|
-
async function installHeader() {
|
|
126
|
-
if (cachedInstallHeader)
|
|
127
|
-
return cachedInstallHeader;
|
|
128
|
-
try {
|
|
129
|
-
const idScript = path.join(repoDir(), "scripts", "identity.py");
|
|
130
|
-
if (!fs.existsSync(idScript))
|
|
131
|
-
return null;
|
|
132
|
-
const res = await runPython("scripts/identity.py", ["header"], { timeoutMs: 10_000 });
|
|
133
|
-
const header = (res.stdout || "").trim();
|
|
134
|
-
if (res.code === 0 && header) {
|
|
135
|
-
cachedInstallHeader = header;
|
|
136
|
-
return header;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
/* best-effort */
|
|
141
|
-
}
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
// Buffer one raw line. Called from the repo.ts line sink, so it must be cheap
|
|
145
|
-
// and total non-throwing.
|
|
146
|
-
export function logLine(stream, line, context) {
|
|
147
|
-
if (!LOG_STREAM_ENABLED)
|
|
148
|
-
return;
|
|
149
|
-
try {
|
|
150
|
-
logBuffer.push({
|
|
151
|
-
ts: new Date().toISOString(),
|
|
152
|
-
stream,
|
|
153
|
-
line: line.length > LOG_MAX_LINE_LEN ? line.slice(0, LOG_MAX_LINE_LEN) : line,
|
|
154
|
-
context: context || "",
|
|
155
|
-
});
|
|
156
|
-
if (logBuffer.length > LOG_MAX_BUFFER) {
|
|
157
|
-
// Drop oldest to bound memory; the newest lines are the most useful.
|
|
158
|
-
logDropped += logBuffer.length - LOG_MAX_BUFFER;
|
|
159
|
-
logBuffer.splice(0, logBuffer.length - LOG_MAX_BUFFER);
|
|
160
|
-
}
|
|
161
|
-
if (logBuffer.length >= LOG_FLUSH_BATCH)
|
|
162
|
-
void flushLogs();
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
/* never throw into the run() boundary */
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
export async function flushLogs() {
|
|
169
|
-
if (!LOG_STREAM_ENABLED)
|
|
170
|
-
return;
|
|
171
|
-
if (logFlushing || logBuffer.length === 0)
|
|
172
|
-
return;
|
|
173
|
-
logFlushing = true;
|
|
174
|
-
try {
|
|
175
|
-
const header = await installHeader();
|
|
176
|
-
if (!header)
|
|
177
|
-
return; // runtime not unpacked yet; keep buffering
|
|
178
|
-
const base = (process.env.AUTOPOSTER_API_BASE || "https://s4l.ai").replace(/\/+$/, "");
|
|
179
|
-
// Drain in <=200-line POSTs until the buffer empties (or a POST fails).
|
|
180
|
-
while (logBuffer.length > 0) {
|
|
181
|
-
const batch = logBuffer.splice(0, LOG_MAX_PER_POST);
|
|
182
|
-
const lines = batch.map((b) => ({
|
|
183
|
-
ts: b.ts,
|
|
184
|
-
stream: b.stream,
|
|
185
|
-
line: b.line,
|
|
186
|
-
context: b.context || undefined,
|
|
187
|
-
}));
|
|
188
|
-
try {
|
|
189
|
-
const resp = await fetch(`${base}/api/v1/installations/logs`, {
|
|
190
|
-
method: "POST",
|
|
191
|
-
headers: { "X-Installation": header, "content-type": "application/json" },
|
|
192
|
-
body: JSON.stringify({ lines }),
|
|
193
|
-
signal: AbortSignal.timeout(15_000),
|
|
194
|
-
});
|
|
195
|
-
if (!resp.ok) {
|
|
196
|
-
// Drop this batch (don't re-buffer): a persistent 4xx/5xx would grow
|
|
197
|
-
// the buffer unbounded. The raw stream is best-effort.
|
|
198
|
-
console.error(`[social-autoposter-mcp] log flush http ${resp.status}`);
|
|
199
|
-
break;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
catch (err) {
|
|
203
|
-
// Network blip: drop this batch, stop draining, try again next tick.
|
|
204
|
-
console.error("[social-autoposter-mcp] log flush failed:", err?.message || err);
|
|
205
|
-
break;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
if (logDropped > 0) {
|
|
209
|
-
console.error(`[social-autoposter-mcp] log stream dropped ${logDropped} line(s) on overflow`);
|
|
210
|
-
logDropped = 0;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
finally {
|
|
214
|
-
logFlushing = false;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
// Register the repo.ts line sink and start the periodic flush. Idempotent.
|
|
218
|
-
export function startLogStreaming() {
|
|
219
|
-
if (!LOG_STREAM_ENABLED || logStreamingStarted)
|
|
220
|
-
return;
|
|
221
|
-
logStreamingStarted = true;
|
|
222
|
-
try {
|
|
223
|
-
setLineSink((line, stream, context) => logLine(stream, line, context));
|
|
224
|
-
logTimer = setInterval(() => void flushLogs(), LOG_FLUSH_MS);
|
|
225
|
-
logTimer.unref();
|
|
226
|
-
}
|
|
227
|
-
catch (err) {
|
|
228
|
-
console.error("[social-autoposter-mcp] log streaming start failed:", err?.message || err);
|
|
229
|
-
}
|
|
230
|
-
}
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.6.
|
|
5
|
+
"version": "1.6.87",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts. Thin desktop client over the S4L pipeline.",
|
|
7
7
|
"long_description": "A guided assistant that drafts, reviews, and autopilots X/Twitter posts.\nTo get started:\n1. Click **Configure** and set every tool permission to **Always Allow**.\n2. Copy this prompt: **Set me up on S4L end to end**.\n3. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
|
|
8
8
|
"author": {
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/social-autoposter-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.87",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Insert/replace the Capstacker project entry in config.json. Idempotent."""
|
|
3
|
+
import json, os, sys
|
|
4
|
+
|
|
5
|
+
CFG = os.path.join(os.path.dirname(__file__), "..", "config.json")
|
|
6
|
+
CFG = os.path.abspath(CFG)
|
|
7
|
+
|
|
8
|
+
entry = {
|
|
9
|
+
"name": "Capstacker",
|
|
10
|
+
"display_name": "Capstacker",
|
|
11
|
+
"weight": 15,
|
|
12
|
+
"description": (
|
|
13
|
+
"Infrastructure / dealroom platform for outcome-based and equity compensation "
|
|
14
|
+
"between early-stage startups and fractional operators, agencies, and specialists. "
|
|
15
|
+
"Lets cash-constrained founders hire fractional execs (CMO/CFO/etc.), agencies, and "
|
|
16
|
+
"advisors and pay via milestones, revenue share, equity, deferred cash, or success "
|
|
17
|
+
"fees instead of cash upfront. Provides benchmarked terms, standardized two-document "
|
|
18
|
+
"contracts (Operator Agreement + Project Assignment/Exhibit A), milestone tracking, "
|
|
19
|
+
"and integrated payouts (Stripe Connect + Trustap escrow). Capstacker is a platform, "
|
|
20
|
+
"NOT a party to any deal; it does not employ, guarantee payment, or resolve disputes."
|
|
21
|
+
),
|
|
22
|
+
"website": "https://capstacker.io",
|
|
23
|
+
"short_links_live": False,
|
|
24
|
+
"short_links_host": "https://s4l.ai",
|
|
25
|
+
"voice_relationship": "third_party",
|
|
26
|
+
"get_started_link": "https://capstacker.io/accounts/signup/",
|
|
27
|
+
"booking_link": "https://capstacker.io/",
|
|
28
|
+
"billing": {
|
|
29
|
+
"model": "$100/mo soft budget + $2/1k impressions + $50/1k clicks",
|
|
30
|
+
"subscription": "$100/month (charge_automatically, active 2026-06-23)",
|
|
31
|
+
"stripe_customer": "cus_Ukgcq8GuzQOFY9",
|
|
32
|
+
"stripe_subscription": "sub_1TlBFjRzrfmaooMLDzPbDqsp",
|
|
33
|
+
"first_invoice": "GAHFIKJC-0001 (paid)"
|
|
34
|
+
},
|
|
35
|
+
"founders": {
|
|
36
|
+
"mustafa": (
|
|
37
|
+
"Mustafa Abbasoglu, founder of Capstacker.io. Contact mustafa@capstacker.io. "
|
|
38
|
+
"Came in via the S4L web-chat agent (WEB-CHAT #66, 2026-06-16). Wants pay-per-click "
|
|
39
|
+
"optionality over impressions; thinks in cap-table impact. Onboarded 2026-06-23."
|
|
40
|
+
)
|
|
41
|
+
},
|
|
42
|
+
"founder_accounts": {
|
|
43
|
+
"real_name": "Mustafa Abbasoglu",
|
|
44
|
+
"support_email": "mustafa@capstacker.io"
|
|
45
|
+
},
|
|
46
|
+
"icp": (
|
|
47
|
+
"US-based early-stage founders (pre-Series A) who are cash-constrained but high-growth, "
|
|
48
|
+
"negotiating with service providers (agencies, fractional CxOs, lawyers, advisors, "
|
|
49
|
+
"recruiters) and want to structure deals with deferred fees, equity components, or "
|
|
50
|
+
"milestone-based payments instead of paying cash upfront. Comfortable with non-standard "
|
|
51
|
+
"deal structures; think in terms of cap-table impact, not just invoices. Secondary "
|
|
52
|
+
"(supply side): fractional leaders, studios, and agencies who want to offer upside/"
|
|
53
|
+
"performance deals without the legal, trust, and admin overhead. NOT hourly freelancers."
|
|
54
|
+
),
|
|
55
|
+
"target_icp": (
|
|
56
|
+
"Pre-seed to Series A startup founders, fractional CMOs/CFOs/COOs, growth and dev "
|
|
57
|
+
"agencies, startup advisors, fundraising/finance/legal specialists."
|
|
58
|
+
),
|
|
59
|
+
"job_titles": [
|
|
60
|
+
"founder", "co-founder", "startup CEO", "fractional CMO", "fractional CFO",
|
|
61
|
+
"fractional COO", "fractional executive", "growth lead", "agency owner",
|
|
62
|
+
"agency founder", "marketing agency owner", "startup advisor", "fundraising advisor",
|
|
63
|
+
"startup consultant", "head of growth"
|
|
64
|
+
],
|
|
65
|
+
"geo_focus": (
|
|
66
|
+
"Primary US (founder-stated ICP is US-based). UK relevant on the supply side "
|
|
67
|
+
"(site cites 110K UK fractional roles, up from 2K in 2 years). Confirm with founder."
|
|
68
|
+
),
|
|
69
|
+
"pricing_capstacker": {
|
|
70
|
+
"operators": "Free to join the Operator Network. 5% platform fee, charged only when a deal pays out. No subscription, no lock-in.",
|
|
71
|
+
"startups": "Subscription for platform access (structuring, tracking, paying deals in one place). Optional 'Operator Introductions' add-on, charged only if a deal is initiated.",
|
|
72
|
+
"payments": "Funds secured upfront via escrow-like infrastructure (Trustap); released as milestones are approved. Payouts via Stripe Connect.",
|
|
73
|
+
"speed": "Deals close in ~1-2 weeks (vs 4-8 weeks traditional senior hire / weeks of vendor shopping + $3K-$5K legal)."
|
|
74
|
+
},
|
|
75
|
+
"competitor_domains": [
|
|
76
|
+
"upwork.com", "toptal.com", "fractionaljobs.io", "deel.com", "ruul.io",
|
|
77
|
+
"lightercapital.com", "clear.co", "clearco.com", "carta.com", "rippling.com",
|
|
78
|
+
"contra.com", "gun.io", "a.team", "pangea.app", "continuum.work", "braintrust.com"
|
|
79
|
+
],
|
|
80
|
+
"competitive_positioning": {
|
|
81
|
+
"vs_upwork_toptal": "Upwork and Toptal are cash-only and transactional, priced per hour/project. Capstacker is the deal-structuring layer for outcome/equity/deferred compensation, built for fractional execs and agencies paid for results, not freelancers billing time.",
|
|
82
|
+
"vs_fractionaljobs": "fractionaljobs.io matches supply and demand but offers no deal-structuring, contracts, milestone tracking, or payout layer. Capstacker is the infrastructure that makes the deal actually close and pay out.",
|
|
83
|
+
"vs_deel_ruul_rippling": "Deel, Ruul, and Rippling handle compliance and payouts but assume cash terms are already agreed. They are payroll/EOR, a different category. Capstacker structures the non-cash part (equity, deferred, milestone, success fees) and then pays out via Stripe Connect. Position as adjacent, not head-to-head.",
|
|
84
|
+
"vs_carta": "Carta manages the cap table after equity is granted. Capstacker structures the operator/service-provider equity deal in the first place. Complementary, not competing.",
|
|
85
|
+
"vs_lighter_capital_clearco": "Lighter Capital and Clearco are debt a founder takes on to pay providers in cash. Capstacker makes that unnecessary by letting the founder pay in equity / on outcomes instead of borrowing."
|
|
86
|
+
},
|
|
87
|
+
"search_topics": [
|
|
88
|
+
"fractional CMO equity", "fractional CFO startup", "hire fractional executive startup",
|
|
89
|
+
"pay contractor in equity", "pay agency in equity", "equity for advisors",
|
|
90
|
+
"advisor equity vesting", "how much equity for fractional CMO", "deferred compensation startup",
|
|
91
|
+
"milestone based contractor payment", "performance based marketing agency",
|
|
92
|
+
"revenue share agency deal", "agency equity deal", "outcome based pricing agency",
|
|
93
|
+
"can't afford senior hire startup", "extend startup runway", "preserve startup runway",
|
|
94
|
+
"startup legal help deferred payment", "non-cash compensation startup",
|
|
95
|
+
"cap table service providers", "equity compensation contractor",
|
|
96
|
+
"Toptal alternative", "Upwork alternative startups", "fractionaljobs alternative",
|
|
97
|
+
"Deel alternative equity", "Clearco alternative", "fractional hiring marketplace",
|
|
98
|
+
"fractional executive marketplace", "hire growth agency equity", "startup advisor compensation",
|
|
99
|
+
"vesting schedule for advisor", "SAFE for service providers", "milestone payments contractor escrow"
|
|
100
|
+
],
|
|
101
|
+
"subreddits": [
|
|
102
|
+
{"name": "startups", "fit": "PRIMARY", "notes": "Founders constantly discuss hiring, runway, equity splits, fractional help, agency burn. Core demand-side audience."},
|
|
103
|
+
{"name": "Entrepreneur", "fit": "PRIMARY", "notes": "Broad founder audience; hiring/compensation/runway threads. Strict on self-promo; keep comments substantive."},
|
|
104
|
+
{"name": "SaaS", "fit": "PRIMARY", "notes": "Early SaaS founders making fractional CMO / growth-agency decisions."},
|
|
105
|
+
{"name": "ycombinator", "fit": "SECONDARY", "notes": "Pre-seed/seed founders, equity-literate, cap-table aware."},
|
|
106
|
+
{"name": "EntrepreneurRideAlong", "fit": "SECONDARY", "notes": "Hands-on early founders bootstrapping growth, can't afford full-time senior hires."},
|
|
107
|
+
{"name": "smallbusiness", "fit": "SECONDARY", "notes": "Owners weighing agency vs in-house; less equity-native, more outcome-based angle."},
|
|
108
|
+
{"name": "agency", "fit": "SECONDARY", "notes": "Supply side: agency owners considering performance/equity deals with startup clients."},
|
|
109
|
+
{"name": "marketing", "fit": "TERTIARY", "notes": "Growth/agency operators; lead with performance-based engagement angle."},
|
|
110
|
+
{"name": "venturecapital", "fit": "TERTIARY", "notes": "Equity/runway literate; use for higher-level cap-table-impact pieces."}
|
|
111
|
+
],
|
|
112
|
+
"subreddits_blocked": [
|
|
113
|
+
{"name": "forhire", "reason": "Cash gig/hourly marketplace spam, wrong audience (we are not a freelance job board)"},
|
|
114
|
+
{"name": "freelance", "reason": "Hourly-billing freelancers; founder explicitly is NOT targeting freelancers billing time"},
|
|
115
|
+
{"name": "slavelabour", "reason": "Cheap-gig sub, off-brand"}
|
|
116
|
+
],
|
|
117
|
+
"messaging": (
|
|
118
|
+
"Hire the fractional execs, agencies, and specialists you can't pay full cash for, "
|
|
119
|
+
"and pay on outcomes: milestones, revenue share, equity, or deferred fees. Benchmarked "
|
|
120
|
+
"terms, standardized contracts, milestone tracking, and secure payouts in one place. "
|
|
121
|
+
"Close in days, not weeks. Extend your runway instead of burning it."
|
|
122
|
+
),
|
|
123
|
+
"content_angle": (
|
|
124
|
+
"Lead with the founder pain: senior operators cost $180K+, agencies want $20K upfront, "
|
|
125
|
+
"legal bills $600/hr, and you need all of them on pre-Series-A runway. Capstacker is the "
|
|
126
|
+
"rails that make equity/outcome/deferred deals actually close and pay out fairly. For "
|
|
127
|
+
"supply-side threads (agency/fractional operators), lead with 'offer upside deals without "
|
|
128
|
+
"the legal/trust/admin nightmare.' Always frame as the deal-structuring + payout layer, "
|
|
129
|
+
"never as a freelance job board or a payroll tool."
|
|
130
|
+
),
|
|
131
|
+
"content_guardrails": [
|
|
132
|
+
"Capstacker is a PLATFORM, not a party to the deal. Never claim it employs anyone, guarantees payment, or resolves disputes. The agreement is always directly between client and operator.",
|
|
133
|
+
"Never give legal, tax, or securities/investment advice. Equity compensation has real securities and tax implications; point people to the platform and their own counsel, do not advise on specifics or quote 'safe' equity percentages.",
|
|
134
|
+
"Never promise specific equity percentages, returns, or outcomes. Benchmarks are aggregated ranges, not guarantees.",
|
|
135
|
+
"Operators are fractional execs, agencies, studios, and specialists paid for outcomes, NOT hourly freelancers billing time. Never frame Capstacker as an Upwork/Fiverr-style gig marketplace.",
|
|
136
|
+
"Deel, Ruul, Rippling, and Carta are adjacent/complementary (payroll/EOR/cap-table), not head-to-head competitors. Do not trash them; position Capstacker as the layer they assume already exists (the structured non-cash deal). Stripe Connect + Trustap actually power Capstacker payouts, so never disparage Stripe.",
|
|
137
|
+
"Em dashes and en dashes cause UTF-8 corruption in some channels. Use commas, semicolons, or separate sentences.",
|
|
138
|
+
"Reddit comments must read as hand-written and substantive; startup/entrepreneur subs are strict on thinly-veiled promotion."
|
|
139
|
+
],
|
|
140
|
+
"platforms_disabled": [],
|
|
141
|
+
"contact": "mustafa@capstacker.io",
|
|
142
|
+
"site_structure": {
|
|
143
|
+
"marketing_root": ["/", "/about/", "/how-agreements-work/", "/pricing/", "/for-operators/", "/for-investors/", "/faq/", "/blog/"],
|
|
144
|
+
"compare_pages": ["/vs/upwork/", "/vs/deel/", "/vs/safe-and-fast/"],
|
|
145
|
+
"auth": ["/accounts/login/", "/accounts/signup/"]
|
|
146
|
+
},
|
|
147
|
+
"notes_pending": [
|
|
148
|
+
"PostHog project not yet provisioned for Capstacker; add posthog.project_id + api_key_env once created (analytics wiring check will flag until then).",
|
|
149
|
+
"Confirm with founder: demand-side (founders) vs supply-side (operators) priority; geo (US-only vs US+UK); his X/Twitter handle and whether to @-mention; converting use-case to lead with; any off-limits competitors/subs; compliance phrasing limits.",
|
|
150
|
+
"Founder requested pay-per-click optionality over impressions; pricing currently $100/mo + $2/1k impr + $50/1k clicks. Revisit split after first cycle data."
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
with open(CFG) as f:
|
|
155
|
+
cfg = json.load(f)
|
|
156
|
+
|
|
157
|
+
projects = cfg.setdefault("projects", [])
|
|
158
|
+
existing_idx = next((i for i, p in enumerate(projects) if (p.get("name") or "").lower() == "capstacker"), None)
|
|
159
|
+
if existing_idx is not None:
|
|
160
|
+
projects[existing_idx] = entry
|
|
161
|
+
action = "REPLACED"
|
|
162
|
+
else:
|
|
163
|
+
projects.append(entry)
|
|
164
|
+
action = "APPENDED"
|
|
165
|
+
|
|
166
|
+
with open(CFG, "w") as f:
|
|
167
|
+
json.dump(cfg, f, indent=1, ensure_ascii=False)
|
|
168
|
+
f.write("\n")
|
|
169
|
+
|
|
170
|
+
print(f"{action} Capstacker entry. Total projects now: {len(projects)}")
|
|
171
|
+
print("names:", [p.get("name") for p in projects])
|
|
@@ -252,145 +252,6 @@ CLEAR_EXPR = (
|
|
|
252
252
|
)
|
|
253
253
|
|
|
254
254
|
|
|
255
|
-
# --- the interactive draft sidebar ------------------------------------------
|
|
256
|
-
# A left-edge panel that lists the drafts waiting to post. Unlike the status
|
|
257
|
-
# overlay (which is pointer-events:none and purely cosmetic), the sidebar is
|
|
258
|
-
# INTERACTIVE: pointer-events:auto, buttons the user can click. Because a button
|
|
259
|
-
# runs in the page's JS world and cannot call Python/CDP directly, the click
|
|
260
|
-
# bridge is a poll: a click stashes {id, ts} on window.__sapsClick, and the
|
|
261
|
-
# Python watch loop reads + clears it each tick, then drives the preview
|
|
262
|
-
# (navigate to the tweet + type the draft into the reply box, NEVER submit).
|
|
263
|
-
#
|
|
264
|
-
# Same CSP discipline as the status overlay: createElement + element.style.<prop>
|
|
265
|
-
# + textContent only, click handlers via addEventListener (NOT inline attrs / no
|
|
266
|
-
# innerHTML). The two elements are kept deliberately separate so the sidebar's
|
|
267
|
-
# pointer-events:auto can never bleed into the cosmetic status overlay and start
|
|
268
|
-
# intercepting the automation's own clicks.
|
|
269
|
-
_SIDEBAR_BODY = r"""
|
|
270
|
-
window.__sapsPaintSidebar = function(payload){
|
|
271
|
-
try {
|
|
272
|
-
var ID = "__saps_sidebar";
|
|
273
|
-
var drafts = (payload && payload.drafts) || [];
|
|
274
|
-
var note = (payload && payload.note) || "";
|
|
275
|
-
function mk(tag, parent){ var e=document.createElement(tag); if(parent)parent.appendChild(e); return e; }
|
|
276
|
-
|
|
277
|
-
var root = document.getElementById(ID);
|
|
278
|
-
var rebuilt = false;
|
|
279
|
-
if(!root || !root.isConnected){
|
|
280
|
-
root = mk("div", document.documentElement); root.id = ID;
|
|
281
|
-
var s = root.style;
|
|
282
|
-
s.position="fixed"; s.top="0"; s.right="0"; s.height="100vh"; s.width="264px";
|
|
283
|
-
s.zIndex="2147483646"; s.pointerEvents="auto"; s.boxSizing="border-box";
|
|
284
|
-
s.padding="14px 12px"; s.overflowY="auto";
|
|
285
|
-
s.background="rgba(15,15,17,0.96)"; s.color="#fff";
|
|
286
|
-
s.font="13px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif";
|
|
287
|
-
s.borderLeft="1px solid rgba(255,255,255,0.12)";
|
|
288
|
-
s.boxShadow="-2px 0 18px rgba(0,0,0,0.40)";
|
|
289
|
-
s.backdropFilter="blur(6px)"; s.webkitBackdropFilter="blur(6px)";
|
|
290
|
-
rebuilt = true;
|
|
291
|
-
}
|
|
292
|
-
var st = window.__sapsSidebarState || (window.__sapsSidebarState = {});
|
|
293
|
-
var sig = drafts.map(function(d){return d.id;}).join(",");
|
|
294
|
-
if(rebuilt || st.sig !== sig){
|
|
295
|
-
st.sig = sig;
|
|
296
|
-
while(root.firstChild) root.removeChild(root.firstChild);
|
|
297
|
-
|
|
298
|
-
var head = mk("div", root);
|
|
299
|
-
head.style.display="flex"; head.style.alignItems="center"; head.style.gap="8px";
|
|
300
|
-
var ttl = mk("span", head); ttl.textContent="Drafts to post";
|
|
301
|
-
ttl.style.fontWeight="600";
|
|
302
|
-
var cnt = mk("span", head); cnt.textContent=String(drafts.length);
|
|
303
|
-
cnt.style.marginLeft="auto"; cnt.style.opacity="0.55"; cnt.style.fontSize="11px";
|
|
304
|
-
cnt.style.fontVariantNumeric="tabular-nums";
|
|
305
|
-
|
|
306
|
-
var sub = mk("div", root);
|
|
307
|
-
sub.textContent="Click one to load it into the reply box. It won\u2019t post.";
|
|
308
|
-
sub.style.opacity="0.6"; sub.style.fontSize="11px"; sub.style.margin="3px 0 10px";
|
|
309
|
-
|
|
310
|
-
var noteEl = mk("div", root); st._note = noteEl; noteEl.id="__saps_sb_note";
|
|
311
|
-
noteEl.style.minHeight="14px"; noteEl.style.fontSize="11px";
|
|
312
|
-
noteEl.style.opacity="0.85"; noteEl.style.marginBottom="10px";
|
|
313
|
-
noteEl.textContent = note;
|
|
314
|
-
|
|
315
|
-
if(!drafts.length){
|
|
316
|
-
var empty = mk("div", root);
|
|
317
|
-
empty.textContent="No drafts waiting. Run a draft cycle.";
|
|
318
|
-
empty.style.opacity="0.5"; empty.style.fontSize="12px";
|
|
319
|
-
}
|
|
320
|
-
drafts.forEach(function(d){
|
|
321
|
-
var b = mk("div", root); var bs = b.style;
|
|
322
|
-
bs.cursor="pointer"; bs.padding="9px 10px"; bs.marginBottom="8px";
|
|
323
|
-
bs.borderRadius="10px"; bs.border="1px solid rgba(255,255,255,0.10)";
|
|
324
|
-
bs.background=(window.__sapsSelectedId==d.id)?"rgba(255,255,255,0.16)":"rgba(255,255,255,0.06)";
|
|
325
|
-
b.setAttribute("data-saps-id", String(d.id));
|
|
326
|
-
|
|
327
|
-
var meta = mk("div", b);
|
|
328
|
-
meta.style.display="flex"; meta.style.gap="6px"; meta.style.alignItems="center";
|
|
329
|
-
meta.style.marginBottom="4px";
|
|
330
|
-
if(d.project){
|
|
331
|
-
var proj = mk("span", meta); proj.textContent=d.project;
|
|
332
|
-
proj.style.fontSize="10px"; proj.style.fontWeight="600";
|
|
333
|
-
proj.style.padding="1px 6px"; proj.style.borderRadius="6px";
|
|
334
|
-
proj.style.background="rgba(255,255,255,0.14)";
|
|
335
|
-
}
|
|
336
|
-
if(d.handle){
|
|
337
|
-
var who = mk("span", meta); who.textContent="@"+d.handle;
|
|
338
|
-
who.style.fontSize="11px"; who.style.opacity="0.6";
|
|
339
|
-
who.style.overflow="hidden"; who.style.textOverflow="ellipsis"; who.style.whiteSpace="nowrap";
|
|
340
|
-
}
|
|
341
|
-
var txt = mk("div", b);
|
|
342
|
-
txt.textContent = d.draft_text || "(no draft text)";
|
|
343
|
-
txt.style.fontSize="12px"; txt.style.lineHeight="1.35";
|
|
344
|
-
txt.style.display="-webkit-box"; txt.style.webkitLineClamp="3";
|
|
345
|
-
txt.style.webkitBoxOrient="vertical"; txt.style.overflow="hidden";
|
|
346
|
-
|
|
347
|
-
b.addEventListener("mouseenter", function(){ bs.background="rgba(255,255,255,0.12)"; });
|
|
348
|
-
b.addEventListener("mouseleave", function(){
|
|
349
|
-
bs.background=(window.__sapsSelectedId==d.id)?"rgba(255,255,255,0.16)":"rgba(255,255,255,0.06)";
|
|
350
|
-
});
|
|
351
|
-
b.addEventListener("click", function(){
|
|
352
|
-
window.__sapsClick = {id: d.id, ts: Date.now()};
|
|
353
|
-
window.__sapsSelectedId = d.id;
|
|
354
|
-
var all = root.querySelectorAll("[data-saps-id]");
|
|
355
|
-
for(var i=0;i<all.length;i++){ all[i].style.background="rgba(255,255,255,0.06)"; }
|
|
356
|
-
bs.background="rgba(255,255,255,0.16)";
|
|
357
|
-
if(st._note) st._note.textContent="Loading draft into the reply box\u2026";
|
|
358
|
-
});
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
if(st._note && typeof note === "string" && note.length) st._note.textContent = note;
|
|
362
|
-
} catch(e) { /* sidebar is best-effort, never throw into the page */ }
|
|
363
|
-
};
|
|
364
|
-
"""
|
|
365
|
-
|
|
366
|
-
SIDEBAR_PAINT_EXPR = "(payload) => { " + _SIDEBAR_BODY + " try { window.__sapsPaintSidebar(payload); } catch(e){} }"
|
|
367
|
-
|
|
368
|
-
# Update ONLY the note line without rebuilding the button list (cheap, called on
|
|
369
|
-
# preview start / success / error).
|
|
370
|
-
SB_NOTE_EXPR = (
|
|
371
|
-
"(note) => { try { var e=document.getElementById('__saps_sb_note'); "
|
|
372
|
-
"if(e) e.textContent=note; } catch(e){} }"
|
|
373
|
-
)
|
|
374
|
-
|
|
375
|
-
# Read-and-clear the pending click set by a sidebar button.
|
|
376
|
-
READ_CLICK_EXPR = "() => { var c = window.__sapsClick || null; window.__sapsClick = null; return c; }"
|
|
377
|
-
|
|
378
|
-
CLEAR_SB_EXPR = "() => { var e=document.getElementById('__saps_sidebar'); if(e&&e.remove)e.remove(); }"
|
|
379
|
-
|
|
380
|
-
# Reply composer selectors (mirrors twitter_browser._wait_for_reply_textbox).
|
|
381
|
-
_REPLY_SELECTORS = (
|
|
382
|
-
'[data-testid="tweetTextarea_0"]',
|
|
383
|
-
'[role="textbox"][aria-label="Post text"]',
|
|
384
|
-
'[role="textbox"][aria-label="Tweet your reply"]',
|
|
385
|
-
'[role="textbox"][aria-label="Post your reply"]',
|
|
386
|
-
)
|
|
387
|
-
|
|
388
|
-
# Whether the sidebar is enabled at all (set SAPS_SIDEBAR=0 to disable).
|
|
389
|
-
SIDEBAR_ENABLED = os.environ.get("SAPS_SIDEBAR", "1").strip() != "0"
|
|
390
|
-
# How often (seconds) to re-fetch the drafts list from the API.
|
|
391
|
-
SIDEBAR_REFRESH_SEC = int(os.environ.get("SAPS_SIDEBAR_REFRESH_SEC", "12"))
|
|
392
|
-
|
|
393
|
-
|
|
394
255
|
def _build_init_script(title: str, reassure: str, status: str) -> str:
|
|
395
256
|
"""add_init_script body: define the painter on every new document and seed
|
|
396
257
|
it with the latest known text so a mid-cycle navigation paints instantly."""
|
|
@@ -467,148 +328,6 @@ class Harness:
|
|
|
467
328
|
pass
|
|
468
329
|
return n
|
|
469
330
|
|
|
470
|
-
# --- interactive sidebar -------------------------------------------------
|
|
471
|
-
|
|
472
|
-
def register_sidebar_init(self, drafts: list) -> None:
|
|
473
|
-
"""Rebuild the sidebar on every new document so it survives navigation."""
|
|
474
|
-
seed = json.dumps({"drafts": drafts, "note": ""})
|
|
475
|
-
script = _SIDEBAR_BODY + ("try { window.__sapsPaintSidebar(" + seed + "); } catch(e){}")
|
|
476
|
-
for ctx in self._browser.contexts:
|
|
477
|
-
try:
|
|
478
|
-
ctx.add_init_script(script)
|
|
479
|
-
except Exception:
|
|
480
|
-
pass
|
|
481
|
-
|
|
482
|
-
def paint_sidebar(self, drafts: list, note: str = "") -> int:
|
|
483
|
-
n = 0
|
|
484
|
-
payload = {"drafts": drafts, "note": note}
|
|
485
|
-
for p in self._pages():
|
|
486
|
-
try:
|
|
487
|
-
p.evaluate(SIDEBAR_PAINT_EXPR, payload)
|
|
488
|
-
n += 1
|
|
489
|
-
except Exception:
|
|
490
|
-
pass
|
|
491
|
-
return n
|
|
492
|
-
|
|
493
|
-
def set_sidebar_note(self, note: str) -> None:
|
|
494
|
-
for p in self._pages():
|
|
495
|
-
try:
|
|
496
|
-
p.evaluate(SB_NOTE_EXPR, note)
|
|
497
|
-
except Exception:
|
|
498
|
-
pass
|
|
499
|
-
|
|
500
|
-
def read_click(self):
|
|
501
|
-
"""Return (click_dict, page) for the first pending sidebar click, else (None, None)."""
|
|
502
|
-
for p in self._pages():
|
|
503
|
-
try:
|
|
504
|
-
c = p.evaluate(READ_CLICK_EXPR)
|
|
505
|
-
if c:
|
|
506
|
-
return c, p
|
|
507
|
-
except Exception:
|
|
508
|
-
pass
|
|
509
|
-
return None, None
|
|
510
|
-
|
|
511
|
-
def clear_sidebar(self) -> int:
|
|
512
|
-
n = 0
|
|
513
|
-
for p in self._pages():
|
|
514
|
-
try:
|
|
515
|
-
p.evaluate(CLEAR_SB_EXPR)
|
|
516
|
-
n += 1
|
|
517
|
-
except Exception:
|
|
518
|
-
pass
|
|
519
|
-
return n
|
|
520
|
-
|
|
521
|
-
def _wait_reply_box(self, page, total_ms: int = 30000):
|
|
522
|
-
import time as _t
|
|
523
|
-
deadline = _t.monotonic() + total_ms / 1000.0
|
|
524
|
-
while _t.monotonic() < deadline:
|
|
525
|
-
for sel in _REPLY_SELECTORS:
|
|
526
|
-
try:
|
|
527
|
-
loc = page.locator(sel).first
|
|
528
|
-
if loc.count() > 0 and loc.is_visible():
|
|
529
|
-
return loc
|
|
530
|
-
except Exception:
|
|
531
|
-
pass
|
|
532
|
-
page.wait_for_timeout(500)
|
|
533
|
-
return None
|
|
534
|
-
|
|
535
|
-
def preview_draft(self, page, tweet_url: str, text: str) -> dict:
|
|
536
|
-
"""Navigate to the tweet and TYPE the draft into the reply box.
|
|
537
|
-
|
|
538
|
-
Deliberately stops before submitting: there is no click on the Reply /
|
|
539
|
-
tweetButtonInline button anywhere in this method. It mirrors the
|
|
540
|
-
navigate+locate+type prefix of twitter_browser.reply_to_tweet, minus the
|
|
541
|
-
post step, so the user sees exactly how the reply would look without it
|
|
542
|
-
going live.
|
|
543
|
-
"""
|
|
544
|
-
try:
|
|
545
|
-
try:
|
|
546
|
-
page.goto(tweet_url, wait_until="load", timeout=60000)
|
|
547
|
-
except Exception:
|
|
548
|
-
try:
|
|
549
|
-
page.goto(tweet_url, wait_until="domcontentloaded", timeout=60000)
|
|
550
|
-
except Exception:
|
|
551
|
-
pass
|
|
552
|
-
page.wait_for_timeout(2500)
|
|
553
|
-
try:
|
|
554
|
-
page.wait_for_selector("main", state="attached", timeout=20000)
|
|
555
|
-
except Exception:
|
|
556
|
-
pass
|
|
557
|
-
box = self._wait_reply_box(page, 30000)
|
|
558
|
-
if not box:
|
|
559
|
-
return {"ok": False, "error": "reply_box_not_found"}
|
|
560
|
-
box.click()
|
|
561
|
-
page.wait_for_timeout(400)
|
|
562
|
-
# Clear anything already in the composer so a re-preview is clean.
|
|
563
|
-
try:
|
|
564
|
-
page.keyboard.press("Meta+A")
|
|
565
|
-
page.keyboard.press("Delete")
|
|
566
|
-
except Exception:
|
|
567
|
-
pass
|
|
568
|
-
page.keyboard.type(text, delay=8)
|
|
569
|
-
return {"ok": True}
|
|
570
|
-
except Exception as e:
|
|
571
|
-
return {"ok": False, "error": str(e)}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
# --- drafts data source -----------------------------------------------------
|
|
575
|
-
|
|
576
|
-
def _fetch_drafts(limit: int = 40) -> list:
|
|
577
|
-
"""Fetch the drafts waiting to post (status='drafted') via the s4l.ai API.
|
|
578
|
-
|
|
579
|
-
Returns a compact list the sidebar can render. Best-effort: any failure
|
|
580
|
-
(API down, no identity, SSL hiccup) returns [] so the watch loop never
|
|
581
|
-
crashes the pipeline over a missing sidebar list.
|
|
582
|
-
"""
|
|
583
|
-
try:
|
|
584
|
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
585
|
-
from http_api import api_get
|
|
586
|
-
resp = api_get(
|
|
587
|
-
"/api/v1/twitter-candidates",
|
|
588
|
-
query={"status": "drafted", "limit": str(limit)},
|
|
589
|
-
)
|
|
590
|
-
except BaseException:
|
|
591
|
-
# api_get raises SystemExit (a BaseException, NOT Exception) on terminal
|
|
592
|
-
# HTTP failure; a transient 500 must yield [] for this tick, never kill
|
|
593
|
-
# the watch loop. Catch BaseException so the sidebar degrades gracefully.
|
|
594
|
-
return []
|
|
595
|
-
rows = (resp.get("data") or {}).get("candidates") if isinstance(resp, dict) else None
|
|
596
|
-
rows = rows or (resp.get("candidates") if isinstance(resp, dict) else None) or []
|
|
597
|
-
drafts = []
|
|
598
|
-
for r in rows:
|
|
599
|
-
text = (r.get("draft_reply_text") or "").strip()
|
|
600
|
-
url = r.get("tweet_url") or ""
|
|
601
|
-
if not text or not url:
|
|
602
|
-
continue
|
|
603
|
-
drafts.append({
|
|
604
|
-
"id": r.get("id"),
|
|
605
|
-
"project": r.get("matched_project") or "",
|
|
606
|
-
"handle": r.get("author_handle") or "",
|
|
607
|
-
"tweet_url": url,
|
|
608
|
-
"draft_text": text,
|
|
609
|
-
})
|
|
610
|
-
return drafts
|
|
611
|
-
|
|
612
331
|
|
|
613
332
|
# --- cycle-log -> friendly status -------------------------------------------
|
|
614
333
|
|
|
@@ -736,18 +455,12 @@ def cmd_watch(interval: float = 2.0) -> int:
|
|
|
736
455
|
poster's concurrent CDP session) and only reconnects when the harness Chrome
|
|
737
456
|
comes/goes. Never raises into the pipeline."""
|
|
738
457
|
print(f"watching {LOG_DIR}/twitter-cycle-*.log -> overlay on {CDP_URL} (Ctrl-C to stop)")
|
|
739
|
-
if SIDEBAR_ENABLED:
|
|
740
|
-
print(f"interactive drafts sidebar ON (refresh every {SIDEBAR_REFRESH_SEC}s; SAPS_SIDEBAR=0 to disable)")
|
|
741
458
|
# Treat SIGTERM (launchd unload, `kill`) like Ctrl-C so the overlay is
|
|
742
459
|
# cleared on the way out instead of lingering until the next navigation.
|
|
743
460
|
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
|
|
744
461
|
last_status = None
|
|
745
462
|
h: Harness | None = None
|
|
746
463
|
registered = False
|
|
747
|
-
drafts: list = []
|
|
748
|
-
drafts_by_id: dict = {}
|
|
749
|
-
last_sb_fetch = 0.0
|
|
750
|
-
last_sb_sig = None
|
|
751
464
|
try:
|
|
752
465
|
while True:
|
|
753
466
|
# Never let status computation (log globbing/stat, all racing against
|
|
@@ -763,48 +476,12 @@ def cmd_watch(interval: float = 2.0) -> int:
|
|
|
763
476
|
if not registered:
|
|
764
477
|
# Re-register init on each (re)connect so fresh tabs inherit it.
|
|
765
478
|
h.register_init(TITLE, REASSURE, status)
|
|
766
|
-
if SIDEBAR_ENABLED:
|
|
767
|
-
h.register_sidebar_init(drafts)
|
|
768
479
|
registered = True
|
|
769
480
|
# Repaint every tick even when text is unchanged: the timestamp
|
|
770
481
|
# reset keeps the heartbeat fresh so the dot never looks dead.
|
|
771
482
|
if h.paint(TITLE, REASSURE, status) == 0:
|
|
772
483
|
# No live page (all tabs closed/navigating) -> drop & retry.
|
|
773
484
|
raise RuntimeError("no live page")
|
|
774
|
-
|
|
775
|
-
if SIDEBAR_ENABLED:
|
|
776
|
-
now = time.time()
|
|
777
|
-
if now - last_sb_fetch >= SIDEBAR_REFRESH_SEC:
|
|
778
|
-
drafts = _fetch_drafts()
|
|
779
|
-
drafts_by_id = {str(d["id"]): d for d in drafts}
|
|
780
|
-
last_sb_fetch = now
|
|
781
|
-
sig = ",".join(str(d["id"]) for d in drafts)
|
|
782
|
-
if sig != last_sb_sig:
|
|
783
|
-
# List changed -> re-register init (fresh tabs) + repaint.
|
|
784
|
-
h.register_sidebar_init(drafts)
|
|
785
|
-
last_sb_sig = sig
|
|
786
|
-
h.paint_sidebar(drafts)
|
|
787
|
-
|
|
788
|
-
# Click bridge: a sidebar button stashed {id, ts} -> drive preview.
|
|
789
|
-
click, click_page = h.read_click()
|
|
790
|
-
if click and click_page is not None:
|
|
791
|
-
did = str(click.get("id"))
|
|
792
|
-
d = drafts_by_id.get(did)
|
|
793
|
-
if d is None:
|
|
794
|
-
h.set_sidebar_note("That draft is no longer in the list.")
|
|
795
|
-
elif "posting" in status.lower():
|
|
796
|
-
# Collision guard: a posting step is driving the same
|
|
797
|
-
# Chrome right now. Refuse so we don't fight it.
|
|
798
|
-
h.set_sidebar_note("Busy posting right now \u2014 try again in a moment.")
|
|
799
|
-
else:
|
|
800
|
-
short = (d["draft_text"][:40] + "\u2026") if len(d["draft_text"]) > 40 else d["draft_text"]
|
|
801
|
-
h.set_sidebar_note("Opening tweet + typing draft\u2026")
|
|
802
|
-
print(f"[{time.strftime('%H:%M:%S')}] preview draft id={did} -> {d['tweet_url']}")
|
|
803
|
-
res = h.preview_draft(click_page, d["tweet_url"], d["draft_text"])
|
|
804
|
-
if res.get("ok"):
|
|
805
|
-
h.set_sidebar_note(f"\u2713 Loaded into reply box (not posted): \u201c{short}\u201d")
|
|
806
|
-
else:
|
|
807
|
-
h.set_sidebar_note(f"Couldn\u2019t load draft: {res.get('error')}")
|
|
808
485
|
except Exception:
|
|
809
486
|
# Harness down or transient CDP hiccup; tear down and retry next tick.
|
|
810
487
|
if h is not None:
|
|
@@ -826,10 +503,6 @@ def cmd_watch(interval: float = 2.0) -> int:
|
|
|
826
503
|
h.clear()
|
|
827
504
|
except Exception:
|
|
828
505
|
pass
|
|
829
|
-
try:
|
|
830
|
-
h.clear_sidebar()
|
|
831
|
-
except Exception:
|
|
832
|
-
pass
|
|
833
506
|
try:
|
|
834
507
|
h.__exit__(None, None, None)
|
|
835
508
|
except Exception:
|
|
@@ -842,17 +515,6 @@ def cmd_watch(interval: float = 2.0) -> int:
|
|
|
842
515
|
return 0
|
|
843
516
|
|
|
844
517
|
|
|
845
|
-
def cmd_drafts() -> int:
|
|
846
|
-
"""Print the drafts the sidebar would show (debug helper, no browser needed)."""
|
|
847
|
-
drafts = _fetch_drafts()
|
|
848
|
-
print(f"{len(drafts)} draft(s) waiting:")
|
|
849
|
-
for d in drafts:
|
|
850
|
-
short = (d["draft_text"][:70] + "\u2026") if len(d["draft_text"]) > 70 else d["draft_text"]
|
|
851
|
-
print(f" [{d['id']}] {d['project']} @{d['handle']}: {short}")
|
|
852
|
-
print(f" {d['tweet_url']}")
|
|
853
|
-
return 0
|
|
854
|
-
|
|
855
|
-
|
|
856
518
|
def main(argv: list[str]) -> int:
|
|
857
519
|
if not argv:
|
|
858
520
|
print(__doc__)
|
|
@@ -870,8 +532,6 @@ def main(argv: list[str]) -> int:
|
|
|
870
532
|
if cmd == "watch":
|
|
871
533
|
iv = float(argv[1]) if len(argv) > 1 else 2.0
|
|
872
534
|
return cmd_watch(iv)
|
|
873
|
-
if cmd == "drafts":
|
|
874
|
-
return cmd_drafts()
|
|
875
535
|
print(f"unknown command: {cmd}", file=sys.stderr)
|
|
876
536
|
print(__doc__)
|
|
877
537
|
return 2
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
2
|
+
# THE single release flow for social-autoposter. One command does EVERYTHING and
|
|
3
|
+
# keeps the npm path (Story A) and the .mcpb double-click path (Story B) on one
|
|
4
|
+
# version, because both derive from one bumped repo-root package.json.
|
|
3
5
|
#
|
|
4
6
|
# What it does, end to end:
|
|
5
|
-
# 1.
|
|
6
|
-
#
|
|
7
|
-
#
|
|
7
|
+
# 1. Bump the repo-root package.json (the SINGLE source of truth) and lockfile.
|
|
8
|
+
# Default: patch bump. --bump minor|major, or pin with --version / --tag,
|
|
9
|
+
# or --no-bump to re-release the current version as-is.
|
|
10
|
+
# 2. Build the MCP bundle: vite (panel) + tsc (server) + bundle-pipeline. The
|
|
11
|
+
# embedded pipeline.tgz is `npm pack` of the (now-bumped) repo, so the shell
|
|
12
|
+
# and the bundled Python pipeline CANNOT diverge.
|
|
13
|
+
# 3. Stamp dist/version.json + manifest.json + mcp/package.json(+lock) to match.
|
|
8
14
|
# 4. Pack mcp/ into mcp/social-autoposter.mcpb via the mcpb CLI.
|
|
9
|
-
# 5. Verify
|
|
10
|
-
#
|
|
15
|
+
# 5. Verify: size cap, embedded pipeline.tgz present, version.json + manifest +
|
|
16
|
+
# the pipeline.tgz's OWN internal version all == VERSION (the guard that was
|
|
17
|
+
# missing when 1.6.84 shipped a 1.6.83 pipeline), install tools present.
|
|
18
|
+
# 6. npm publish social-autoposter@VERSION (idempotent; skipped if already live).
|
|
19
|
+
# 7. Create/update GitHub release vX.Y.Z and upload the .mcpb (--clobber).
|
|
11
20
|
#
|
|
12
|
-
#
|
|
21
|
+
# Boxes self-update from the GitHub release: the menu-bar updater polls
|
|
22
|
+
# releases/latest, pulls the new .mcpb, and on next server boot
|
|
23
|
+
# ensurePipelineCurrent() re-extracts pipeline.tgz. No manual box step needed.
|
|
13
24
|
#
|
|
14
25
|
# Usage:
|
|
15
|
-
# bash scripts/release-mcpb.sh
|
|
16
|
-
# bash scripts/release-mcpb.sh --
|
|
17
|
-
# bash scripts/release-mcpb.sh --
|
|
18
|
-
# bash scripts/release-mcpb.sh --
|
|
26
|
+
# bash scripts/release-mcpb.sh # patch bump, npm + .mcpb + GitHub
|
|
27
|
+
# bash scripts/release-mcpb.sh --bump minor
|
|
28
|
+
# bash scripts/release-mcpb.sh --version 1.7.0 # pin an exact version
|
|
29
|
+
# bash scripts/release-mcpb.sh --no-bump # re-release current package.json version
|
|
30
|
+
# bash scripts/release-mcpb.sh --no-npm # skip npm publish (only .mcpb + GitHub)
|
|
31
|
+
# bash scripts/release-mcpb.sh --no-release # build + pack + verify only (no npm, no GitHub)
|
|
32
|
+
# bash scripts/release-mcpb.sh --draft # GitHub release as a draft
|
|
19
33
|
|
|
20
34
|
set -euo pipefail
|
|
21
35
|
|
|
@@ -29,38 +43,82 @@ GH_REPO="m13v/social-autoposter"
|
|
|
29
43
|
SIZE_CAP_MB=180
|
|
30
44
|
|
|
31
45
|
TAG_OVERRIDE=""
|
|
46
|
+
VERSION_OVERRIDE=""
|
|
32
47
|
DO_RELEASE=1
|
|
48
|
+
DO_NPM=1
|
|
49
|
+
DO_BUMP=1
|
|
50
|
+
BUMP_LEVEL="patch"
|
|
33
51
|
DRAFT_FLAG=""
|
|
34
52
|
|
|
35
53
|
while [[ $# -gt 0 ]]; do
|
|
36
54
|
case "$1" in
|
|
37
55
|
--tag) TAG_OVERRIDE="$2"; shift 2 ;;
|
|
38
56
|
--tag=*) TAG_OVERRIDE="${1#*=}"; shift ;;
|
|
57
|
+
--version) VERSION_OVERRIDE="$2"; shift 2 ;;
|
|
58
|
+
--version=*) VERSION_OVERRIDE="${1#*=}"; shift ;;
|
|
59
|
+
--bump) BUMP_LEVEL="$2"; shift 2 ;;
|
|
60
|
+
--bump=*) BUMP_LEVEL="${1#*=}"; shift ;;
|
|
61
|
+
--no-bump) DO_BUMP=0; shift ;;
|
|
62
|
+
--no-npm) DO_NPM=0; shift ;;
|
|
39
63
|
--no-release) DO_RELEASE=0; shift ;;
|
|
40
64
|
--draft) DRAFT_FLAG="--draft"; shift ;;
|
|
41
|
-
-h|--help) sed -n '2,
|
|
65
|
+
-h|--help) sed -n '2,38p' "$0"; exit 0 ;;
|
|
42
66
|
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
|
43
67
|
esac
|
|
44
68
|
done
|
|
45
69
|
|
|
70
|
+
case "$BUMP_LEVEL" in
|
|
71
|
+
patch|minor|major) ;;
|
|
72
|
+
*) echo "invalid --bump level: $BUMP_LEVEL (want patch|minor|major)" >&2; exit 2 ;;
|
|
73
|
+
esac
|
|
74
|
+
|
|
46
75
|
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
|
47
76
|
die() { printf '\033[1mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
48
77
|
|
|
49
78
|
command -v node >/dev/null || die "node not found on PATH"
|
|
50
79
|
command -v mcpb >/dev/null || die "mcpb CLI not found (npm i -g @anthropic-ai/mcpb)"
|
|
51
80
|
|
|
52
|
-
# ---- 1. Resolve version
|
|
81
|
+
# ---- 1. Resolve + WRITE version into the repo-root package.json -------------
|
|
82
|
+
# The repo-root package.json is the SINGLE source of truth: `npm pack` reads it
|
|
83
|
+
# to build the embedded pipeline.tgz, so the bundle shell and the bundled
|
|
84
|
+
# pipeline cannot diverge as long as we bump it BEFORE building (step 2). This
|
|
85
|
+
# closes the 1.6.84-class bug where the shell said X but pipeline.tgz carried
|
|
86
|
+
# the prior version because only the satellites were stamped.
|
|
53
87
|
PKG_VERSION="$(node -p "require('$REPO_ROOT/package.json').version")"
|
|
54
|
-
if [[ -n "$
|
|
55
|
-
|
|
56
|
-
|
|
88
|
+
if [[ -n "$VERSION_OVERRIDE" ]]; then
|
|
89
|
+
VERSION="${VERSION_OVERRIDE#v}"
|
|
90
|
+
elif [[ -n "$TAG_OVERRIDE" ]]; then
|
|
91
|
+
VERSION="${TAG_OVERRIDE#v}"
|
|
92
|
+
elif [[ "$DO_BUMP" == "1" ]]; then
|
|
93
|
+
# Compute the next version without writing a git tag; we own the write below.
|
|
94
|
+
VERSION="$(node -e "
|
|
95
|
+
let [a,b,c]='$PKG_VERSION'.split('.').map(Number);
|
|
96
|
+
const lvl='$BUMP_LEVEL';
|
|
97
|
+
if(lvl==='major'){a++;b=0;c=0;} else if(lvl==='minor'){b++;c=0;} else {c++;}
|
|
98
|
+
console.log(a+'.'+b+'.'+c);
|
|
99
|
+
")"
|
|
57
100
|
else
|
|
58
101
|
VERSION="$PKG_VERSION"
|
|
59
|
-
TAG="v$VERSION"
|
|
60
102
|
fi
|
|
61
|
-
|
|
103
|
+
TAG="v$VERSION"
|
|
104
|
+
|
|
105
|
+
# Write the resolved version into the repo-root package.json + lockfile so the
|
|
106
|
+
# pipeline tarball, npm publish, and all satellites share one number.
|
|
62
107
|
if [[ "$VERSION" != "$PKG_VERSION" ]]; then
|
|
63
|
-
|
|
108
|
+
say "Bumping repo-root package.json $PKG_VERSION -> $VERSION"
|
|
109
|
+
node -e "
|
|
110
|
+
const fs=require('fs');
|
|
111
|
+
for (const p of ['$REPO_ROOT/package.json','$REPO_ROOT/package-lock.json']) {
|
|
112
|
+
if (!fs.existsSync(p)) continue;
|
|
113
|
+
const j=JSON.parse(fs.readFileSync(p,'utf8'));
|
|
114
|
+
j.version='$VERSION';
|
|
115
|
+
if (j.packages && j.packages['']) j.packages[''].version='$VERSION';
|
|
116
|
+
fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');
|
|
117
|
+
console.log(' '+p.replace('$REPO_ROOT/','')+' -> '+j.version);
|
|
118
|
+
}
|
|
119
|
+
"
|
|
120
|
+
else
|
|
121
|
+
say "Releasing $TAG (repo-root package.json already $VERSION)"
|
|
64
122
|
fi
|
|
65
123
|
|
|
66
124
|
# ---- 2. Build the bundle ----------------------------------------------------
|
|
@@ -128,6 +186,14 @@ MANIFEST_VER=$(unzip -p "$BUNDLE" manifest.json 2>/dev/null | node -p "JSON.pars
|
|
|
128
186
|
[[ "$MANIFEST_VER" == "$VERSION" ]] || die "bundle manifest.json=$MANIFEST_VER != $VERSION (Desktop Details panel would show the wrong version)"
|
|
129
187
|
echo " manifest.json: $MANIFEST_VER ok"
|
|
130
188
|
|
|
189
|
+
# THE guard that was missing when 1.6.84 shipped a 1.6.83 pipeline: assert the
|
|
190
|
+
# version INSIDE the embedded pipeline.tgz matches the bundle. ensurePipelineCurrent()
|
|
191
|
+
# on the box trusts version.json to decide whether to re-extract; if the tarball's
|
|
192
|
+
# own package.json lags, the box materializes stale Python and never knows.
|
|
193
|
+
PIPELINE_VER=$(unzip -p "$BUNDLE" dist/pipeline.tgz 2>/dev/null | tar -xzO package/package.json 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null || echo "?")
|
|
194
|
+
[[ "$PIPELINE_VER" == "$VERSION" ]] || die "embedded pipeline.tgz version=$PIPELINE_VER != $VERSION (box would materialize a STALE pipeline; bump repo-root package.json BEFORE build)"
|
|
195
|
+
echo " pipeline.tgz internal version: $PIPELINE_VER ok"
|
|
196
|
+
|
|
131
197
|
for f in "dist/index.js" "dist/runtime.js" "manifest.json"; do
|
|
132
198
|
# grep -c reads all input (no SIGPIPE); anchor on the time column + 3-space
|
|
133
199
|
# gutter so node_modules/.../dist/index.js does not false-match the top-level.
|
|
@@ -141,7 +207,30 @@ if [[ "$DO_RELEASE" == "0" ]]; then
|
|
|
141
207
|
exit 0
|
|
142
208
|
fi
|
|
143
209
|
|
|
144
|
-
# ---- 6.
|
|
210
|
+
# ---- 6. npm publish (Story A: `npx social-autoposter@<v> init`) -------------
|
|
211
|
+
# Same VERSION as the bundle, from the SAME bumped repo-root package.json, so the
|
|
212
|
+
# npm install path and the .mcpb path can never disagree. Idempotent: a version
|
|
213
|
+
# already on the registry is skipped, not failed.
|
|
214
|
+
if [[ "$DO_NPM" == "1" ]]; then
|
|
215
|
+
command -v npm >/dev/null || die "npm not found on PATH"
|
|
216
|
+
NPM_HTTP=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/social-autoposter/$VERSION" || echo "000")
|
|
217
|
+
if [[ "$NPM_HTTP" == "200" ]]; then
|
|
218
|
+
say "npm: social-autoposter@$VERSION already published — skipping"
|
|
219
|
+
else
|
|
220
|
+
say "Publishing social-autoposter@$VERSION to npm"
|
|
221
|
+
( cd "$REPO_ROOT" && npm publish ) || die "npm publish failed"
|
|
222
|
+
# Confirm it actually landed (granular-token whoami lies; a version fetch doesn't).
|
|
223
|
+
for _ in 1 2 3 4 5; do
|
|
224
|
+
sleep 2
|
|
225
|
+
[[ "$(curl -s -o /dev/null -w '%{http_code}' "https://registry.npmjs.org/social-autoposter/$VERSION")" == "200" ]] && break
|
|
226
|
+
done
|
|
227
|
+
echo " npm: social-autoposter@$VERSION live"
|
|
228
|
+
fi
|
|
229
|
+
else
|
|
230
|
+
say "npm publish skipped (--no-npm)"
|
|
231
|
+
fi
|
|
232
|
+
|
|
233
|
+
# ---- 7. GitHub release ------------------------------------------------------
|
|
145
234
|
command -v gh >/dev/null || die "gh CLI not found"
|
|
146
235
|
gh auth status >/dev/null 2>&1 || die "gh not authenticated (run: gh auth login)"
|
|
147
236
|
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
# Idempotent supervisor for the twitter-harness on-screen overlay watcher.
|
|
3
3
|
#
|
|
4
4
|
# WHAT: keeps exactly ONE `harness_overlay.py watch` process alive. That watcher
|
|
5
|
-
# injects the status overlay
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# injects the status overlay into the twitter-harness Chrome window so a human
|
|
6
|
+
# watching the harness sees what the pipeline is doing. (The interactive "Drafts
|
|
7
|
+
# to post" sidebar is OFF by default as of 2026-06-23; opt back in with
|
|
8
|
+
# SAPS_SIDEBAR=1.)
|
|
8
9
|
#
|
|
9
10
|
# WHY a supervisor: the overlay only renders WHILE the watch process runs. It was
|
|
10
11
|
# previously a manual, local-only process, so it never appeared on headless /
|