@m13v/s4l 1.7.1-rc.4 → 1.7.1-rc.6
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/mcp/dist/index.js +12 -3
- package/mcp/dist/runtime.js +78 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/install.mjs +15 -0
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/get-latest-staging-mcpb.sh +67 -0
- package/scripts/reset-test-machine.sh +85 -3
- package/scripts/set-channel.sh +29 -0
- package/scripts/setup_twitter_auth.py +76 -38
package/mcp/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import fs from "node:fs";
|
|
|
22
22
|
import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
|
|
23
23
|
import { applySetup, resolveProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, normalizeStringList, } from "./setup.js";
|
|
24
24
|
import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
|
|
25
|
-
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, menubarRunning, clearMenubarStop, ensurePipelineCurrent, ensureRuntimeProvisioned, } from "./runtime.js";
|
|
25
|
+
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, menubarRunning, clearMenubarStop, ensurePipelineCurrent, ensureRuntimeProvisioned, retryProvisionIfStalled, } from "./runtime.js";
|
|
26
26
|
import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
|
|
27
27
|
import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
|
|
28
28
|
import { initSentry, sendHeartbeat, sendStateSnapshot, captureError, flushSentry, startLogStreaming, flushLogs, logLine } from "./telemetry.js";
|
|
@@ -2465,12 +2465,21 @@ tool("runtime", {
|
|
|
2465
2465
|
});
|
|
2466
2466
|
}
|
|
2467
2467
|
// ---- status (default): runtime install snapshot -----------------------
|
|
2468
|
-
|
|
2468
|
+
let snapshot = runtimeSnapshot();
|
|
2469
2469
|
if (snapshot.runtime_ready) {
|
|
2470
2470
|
completeOnboardingMilestone("runtime_ready");
|
|
2471
2471
|
}
|
|
2472
2472
|
else if (snapshot.progress?.done && !snapshot.progress.ok) {
|
|
2473
|
-
|
|
2473
|
+
// A prior provision failed. Kick a bounded auto-retry so status polling
|
|
2474
|
+
// self-heals a transient failure instead of parking until the next boot;
|
|
2475
|
+
// the provisioner cleans its own partial artifacts, so a retry is safe.
|
|
2476
|
+
// Only surface the failure to the onboarding ledger once retries are spent.
|
|
2477
|
+
if (retryProvisionIfStalled()) {
|
|
2478
|
+
snapshot = runtimeSnapshot(); // reflect the restarted, in-flight run
|
|
2479
|
+
}
|
|
2480
|
+
else {
|
|
2481
|
+
blockOnboardingMilestone("runtime_ready", "runtime_install_failed", snapshot.progress.error || "Runtime installation failed", { outcome: "failed" });
|
|
2482
|
+
}
|
|
2474
2483
|
}
|
|
2475
2484
|
return jsonContent({
|
|
2476
2485
|
...snapshot,
|
package/mcp/dist/runtime.js
CHANGED
|
@@ -509,12 +509,57 @@ export function startProvisioning() {
|
|
|
509
509
|
if (!inFlight) {
|
|
510
510
|
const progress = freshProgress();
|
|
511
511
|
writeProgress(progress);
|
|
512
|
+
// Heartbeat: the long download steps (Chromium ~150MB, Google Chrome DMG)
|
|
513
|
+
// don't call setStep for minutes, so the progress file's updated_at froze
|
|
514
|
+
// and pollers couldn't tell "slow" from "hung." Re-stamp updated_at on a
|
|
515
|
+
// timer while the run is live so status always shows real motion.
|
|
516
|
+
const heartbeat = setInterval(() => {
|
|
517
|
+
try {
|
|
518
|
+
if (!progress.done)
|
|
519
|
+
writeProgress(progress);
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
/* best effort; never let the heartbeat throw into the timer */
|
|
523
|
+
}
|
|
524
|
+
}, 15000);
|
|
525
|
+
if (typeof heartbeat.unref === "function")
|
|
526
|
+
heartbeat.unref();
|
|
512
527
|
inFlight = provision(progress).finally(() => {
|
|
528
|
+
clearInterval(heartbeat);
|
|
513
529
|
inFlight = null;
|
|
514
530
|
});
|
|
515
531
|
}
|
|
516
532
|
return readProgress() ?? freshProgress();
|
|
517
533
|
}
|
|
534
|
+
// Bounded auto-retry for a provision that ended in failure. Called from the
|
|
535
|
+
// runtime `status` handler on each poll: if the last run failed (done && !ok)
|
|
536
|
+
// and nothing is in flight, kick a fresh run so a TRANSIENT failure (a dropped
|
|
537
|
+
// Chromium download, a flaky DMG mount) self-heals during normal status polling
|
|
538
|
+
// instead of parking until the next server boot. The venv/harness steps clean
|
|
539
|
+
// their own partial artifacts, so a retry starts from a clean slate. Capped so a
|
|
540
|
+
// genuinely broken environment (no network, no disk) surfaces the error instead
|
|
541
|
+
// of looping forever. Returns true if it started a retry.
|
|
542
|
+
let autoRetryCount = 0;
|
|
543
|
+
const MAX_AUTO_RETRIES = 3;
|
|
544
|
+
export function retryProvisionIfStalled() {
|
|
545
|
+
try {
|
|
546
|
+
if (runtimeReady())
|
|
547
|
+
return false;
|
|
548
|
+
if (isProvisioning())
|
|
549
|
+
return false;
|
|
550
|
+
const p = readProgress();
|
|
551
|
+
if (!(p && p.done && !p.ok))
|
|
552
|
+
return false; // only retry a real failure
|
|
553
|
+
if (autoRetryCount >= MAX_AUTO_RETRIES)
|
|
554
|
+
return false;
|
|
555
|
+
autoRetryCount += 1;
|
|
556
|
+
startProvisioning();
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
return false; // best-effort; must never break a status poll
|
|
561
|
+
}
|
|
562
|
+
}
|
|
518
563
|
// Boot-time deterministic provisioning: bring the owned runtime to ready on
|
|
519
564
|
// every server start WITHOUT relying on the agent to call `runtime
|
|
520
565
|
// action:'install'`. Called from main() on every server start, which the host
|
|
@@ -548,6 +593,9 @@ async function provision(progress) {
|
|
|
548
593
|
const setStep = (id, status, detail) => {
|
|
549
594
|
const st = progress.steps.find((s) => s.id === id);
|
|
550
595
|
if (st) {
|
|
596
|
+
if (status === "running" && st.status !== "running") {
|
|
597
|
+
st.started_at = new Date().toISOString();
|
|
598
|
+
}
|
|
551
599
|
st.status = status;
|
|
552
600
|
if (detail !== undefined)
|
|
553
601
|
st.detail = detail;
|
|
@@ -658,9 +706,25 @@ async function provision(progress) {
|
|
|
658
706
|
}
|
|
659
707
|
setStep("python", "done");
|
|
660
708
|
// --- Step 3: owned venv ---------------------------------------------------
|
|
709
|
+
// A prior aborted run can leave a PARTIAL .venv (dir created, but the
|
|
710
|
+
// interpreter symlink never landed because the standalone-Python download was
|
|
711
|
+
// cut off). `uv venv` refuses a non-empty target, so every retry then failed
|
|
712
|
+
// identically and the ONLY escape was a human `rm -rf .venv` — the single
|
|
713
|
+
// non-idempotent step that turned a 5-minute install into an hour. Treat a
|
|
714
|
+
// venv with no interpreter as garbage and clear it first (same defensive idiom
|
|
715
|
+
// Step 0 uses for a half-unpacked repo), and pass --clear so uv rebuilds
|
|
716
|
+
// cleanly. This makes re-provision fully self-healing.
|
|
661
717
|
setStep("venv", "running");
|
|
662
718
|
{
|
|
663
|
-
|
|
719
|
+
if (fs.existsSync(VENV_DIR) && !fs.existsSync(VENV_PYTHON)) {
|
|
720
|
+
try {
|
|
721
|
+
fs.rmSync(VENV_DIR, { recursive: true, force: true });
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
/* best effort; --clear below is the second line of defense */
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const r = await sh(uv, ["venv", "--clear", "--python", PYTHON_VERSION, VENV_DIR], {
|
|
664
728
|
env: uvEnv,
|
|
665
729
|
timeoutMs: 120000,
|
|
666
730
|
});
|
|
@@ -714,6 +778,19 @@ async function provision(progress) {
|
|
|
714
778
|
// draft_cycle returned "no candidates". This brings .mcpb to parity with npm.
|
|
715
779
|
setStep("harness", "running");
|
|
716
780
|
{
|
|
781
|
+
// Retry-safety: a clone cut off mid-way leaves HARNESS_DIR present but with
|
|
782
|
+
// no .git, so the "clone if absent" check below skips it and the fetch/reset
|
|
783
|
+
// then fail forever against a broken checkout — the same partial-artifact
|
|
784
|
+
// deadlock the venv step had. Treat a non-git HARNESS_DIR as garbage and
|
|
785
|
+
// rebuild it.
|
|
786
|
+
if (fs.existsSync(HARNESS_DIR) && !fs.existsSync(path.join(HARNESS_DIR, ".git"))) {
|
|
787
|
+
try {
|
|
788
|
+
fs.rmSync(HARNESS_DIR, { recursive: true, force: true });
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
/* best effort */
|
|
792
|
+
}
|
|
793
|
+
}
|
|
717
794
|
// Clone if absent (mkdir parent first), else reuse the checkout.
|
|
718
795
|
if (!fs.existsSync(HARNESS_DIR)) {
|
|
719
796
|
fs.mkdirSync(path.dirname(HARNESS_DIR), { recursive: true });
|
package/mcp/dist/version.json
CHANGED
package/mcp/install.mjs
CHANGED
|
@@ -14,6 +14,8 @@ import path from "node:path";
|
|
|
14
14
|
|
|
15
15
|
const SERVER_KEY = "social-autoposter";
|
|
16
16
|
const UNINSTALL = process.argv.includes("--uninstall");
|
|
17
|
+
const CHANNEL_STAGING = process.argv.includes("--channel-staging");
|
|
18
|
+
const CHANNEL_STABLE = process.argv.includes("--channel-stable");
|
|
17
19
|
|
|
18
20
|
// ---- resolve the absolute paths we want pinned into the spawn env ----------
|
|
19
21
|
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
@@ -135,6 +137,19 @@ for (const t of targets) {
|
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
// Set release channel if requested
|
|
141
|
+
if (!UNINSTALL && (CHANNEL_STAGING || CHANNEL_STABLE)) {
|
|
142
|
+
const stateDir = path.join(home, ".social-autoposter-mcp");
|
|
143
|
+
const channel = CHANNEL_STAGING ? "staging" : "stable";
|
|
144
|
+
try {
|
|
145
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
146
|
+
writeJson(path.join(stateDir, "channel.json"), { channel });
|
|
147
|
+
console.log(`✓ Release channel set to: ${channel}`);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error(`✗ Failed to set channel: ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
138
153
|
console.log("");
|
|
139
154
|
if (ok === targets.length) {
|
|
140
155
|
if (UNINSTALL) {
|
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.7.1-rc.
|
|
5
|
+
"version": "1.7.1-rc.6",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.1-rc.
|
|
3
|
+
"version": "1.7.1-rc.6",
|
|
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,67 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Get the permanent download URL for the latest S4L staging release (.mcpb).
|
|
3
|
+
# Usage:
|
|
4
|
+
# bash scripts/get-latest-staging-mcpb.sh [--download] [--channel] [--path]
|
|
5
|
+
#
|
|
6
|
+
# Options:
|
|
7
|
+
# --download Download the .mcpb to current directory (instead of just printing URL)
|
|
8
|
+
# --channel Also set the local channel to staging after download
|
|
9
|
+
# --path Print the download path instead of the URL (for scripts)
|
|
10
|
+
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
DO_DOWNLOAD=0
|
|
14
|
+
DO_CHANNEL=0
|
|
15
|
+
DO_PATH=0
|
|
16
|
+
|
|
17
|
+
while [[ $# -gt 0 ]]; do
|
|
18
|
+
case "$1" in
|
|
19
|
+
--download) DO_DOWNLOAD=1; shift ;;
|
|
20
|
+
--channel) DO_CHANNEL=1; shift ;;
|
|
21
|
+
--path) DO_PATH=1; shift ;;
|
|
22
|
+
*) echo "Unknown option: $1"; exit 1 ;;
|
|
23
|
+
esac
|
|
24
|
+
done
|
|
25
|
+
|
|
26
|
+
# Fetch the latest pre-release from GitHub API (first page, per_page=1 for speed)
|
|
27
|
+
# Response is a JSON array; use python to extract the first object
|
|
28
|
+
RELEASE_DATA=$(curl -fsS "https://api.github.com/repos/m13v/s4l/releases?per_page=1&pre_release=true" 2>/dev/null | python3 -c "import sys, json; data=json.load(sys.stdin); r=data[0] if data else {}; print(json.dumps(r))")
|
|
29
|
+
|
|
30
|
+
# Extract version and download URL using python for reliable JSON parsing
|
|
31
|
+
LATEST_TAG=$(echo "$RELEASE_DATA" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('tag_name', ''))" 2>/dev/null)
|
|
32
|
+
MCPB_URL=$(echo "$RELEASE_DATA" | python3 -c "
|
|
33
|
+
import sys, json
|
|
34
|
+
data = json.load(sys.stdin)
|
|
35
|
+
for asset in data.get('assets', []):
|
|
36
|
+
if 'social-autoposter.mcpb' in asset.get('name', ''):
|
|
37
|
+
print(asset.get('browser_download_url', ''))
|
|
38
|
+
break
|
|
39
|
+
" 2>/dev/null)
|
|
40
|
+
|
|
41
|
+
if [ -z "$LATEST_TAG" ] || [ -z "$MCPB_URL" ]; then
|
|
42
|
+
echo "Error: Could not find latest staging release" >&2
|
|
43
|
+
exit 1
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
if [ "$DO_PATH" -eq 1 ]; then
|
|
47
|
+
# Return the tag only (for scripting)
|
|
48
|
+
echo "$LATEST_TAG"
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
if [ "$DO_DOWNLOAD" -eq 1 ]; then
|
|
53
|
+
FILENAME="social-autoposter-${LATEST_TAG}.mcpb"
|
|
54
|
+
echo "Downloading $FILENAME from $MCPB_URL" >&2
|
|
55
|
+
curl -fL "$MCPB_URL" -o "$FILENAME"
|
|
56
|
+
echo "$FILENAME"
|
|
57
|
+
|
|
58
|
+
if [ "$DO_CHANNEL" -eq 1 ]; then
|
|
59
|
+
STATE_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}"
|
|
60
|
+
mkdir -p "$STATE_DIR"
|
|
61
|
+
echo '{"channel":"staging"}' > "$STATE_DIR/channel.json"
|
|
62
|
+
echo "✓ Channel set to staging" >&2
|
|
63
|
+
fi
|
|
64
|
+
else
|
|
65
|
+
# Just print the URL
|
|
66
|
+
echo "$MCPB_URL"
|
|
67
|
+
fi
|
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
# disturbing the rest of the environment.
|
|
18
18
|
# --deep FULL NUKE — the plugin reset above PLUS the shared layer it touches:
|
|
19
19
|
# per-agent Chrome profiles + cookies (reddit/linkedin/twitter), the
|
|
20
|
-
# browser-harness backend,
|
|
21
|
-
# cache, Chromium ~1.7G)
|
|
22
|
-
#
|
|
20
|
+
# browser-harness backend, the packaged toolchain (uv binary + uv
|
|
21
|
+
# data/cache, Chromium ~1.7G), the uv PATH shim in your shell rc, the
|
|
22
|
+
# real Google Chrome.app the provisioner installs, and Google's
|
|
23
|
+
# Keystone updater. Reproduces a genuinely bare-metal box (as if S4L
|
|
24
|
+
# had never run). Other tools that rely on uv/Chrome/Chromium
|
|
25
|
+
# re-provision afterward — intended for DEDICATED test machines.
|
|
23
26
|
#
|
|
24
27
|
# Usage:
|
|
25
28
|
# scripts/reset-test-machine.sh # dry run: print what the plugin reset WOULD remove
|
|
@@ -205,6 +208,27 @@ echo "[1] MCP state dir (uv-owned python, venv, materialized repo, runtime.json,
|
|
|
205
208
|
rm_path "$HOME_DIR/.social-autoposter-mcp" "state"
|
|
206
209
|
echo
|
|
207
210
|
|
|
211
|
+
# ---- 1a. compat symlink ~/social-autoposter -> materialized repo -----------
|
|
212
|
+
# The .mcpb provisioner plants ~/social-autoposter as a symlink INTO the state
|
|
213
|
+
# dir (so the pipeline's hardcoded $HOME/social-autoposter paths resolve). The
|
|
214
|
+
# state-dir wipe in [1] leaves it DANGLING. Remove it — but ONLY when it is a
|
|
215
|
+
# symlink resolving into .social-autoposter-mcp, so a developer's real clone (or
|
|
216
|
+
# their own ~/social-autoposter symlink to a working tree) is never touched.
|
|
217
|
+
echo "[1a] compat symlink ~/social-autoposter (plugin-planted)"
|
|
218
|
+
SA_LINK="$HOME_DIR/social-autoposter"
|
|
219
|
+
if [ -L "$SA_LINK" ]; then
|
|
220
|
+
SA_TARGET="$(readlink "$SA_LINK" 2>/dev/null || true)"
|
|
221
|
+
case "$SA_TARGET" in
|
|
222
|
+
*.social-autoposter-mcp/*|*.social-autoposter-mcp) rm_path "$SA_LINK" "compat-symlink" ;;
|
|
223
|
+
*) echo " (leaving $SA_LINK -> $SA_TARGET — not a plugin-planted symlink)" ;;
|
|
224
|
+
esac
|
|
225
|
+
elif [ -e "$SA_LINK" ]; then
|
|
226
|
+
echo " (leaving $SA_LINK — a real directory/clone, not a plugin-planted symlink)"
|
|
227
|
+
else
|
|
228
|
+
echo " (no ~/social-autoposter symlink)"
|
|
229
|
+
fi
|
|
230
|
+
echo
|
|
231
|
+
|
|
208
232
|
# ---- 1b. Claude Desktop .mcpb extension -----------------------------------
|
|
209
233
|
# A .mcpb (Desktop) install scatters THREE artifacts under ~/Library/Application
|
|
210
234
|
# Support/Claude/, NONE of which live in the state dir removed in [1]: the
|
|
@@ -532,6 +556,23 @@ if [ "$DEEP" -eq 1 ]; then
|
|
|
532
556
|
rm_path "$HOME_DIR/.local/bin/uv" "uv-bin"
|
|
533
557
|
rm_path "$HOME_DIR/.local/bin/uvx" "uvx-bin"
|
|
534
558
|
rm_path "$HOME_DIR/.cache/uv" "uv-cache"
|
|
559
|
+
# uv's DATA dir (installed standalone Pythons + tool venvs) and its PATH shim
|
|
560
|
+
# survive the bin removal — a bare box has neither. The uv installer also
|
|
561
|
+
# appends `. "$HOME/.local/bin/env"` to the shell rc files; strip that exact
|
|
562
|
+
# line so no uv PATH shim lingers.
|
|
563
|
+
rm_path "$HOME_DIR/.local/share/uv" "uv-data"
|
|
564
|
+
rm_path "$HOME_DIR/.local/bin/env" "uv-env-script"
|
|
565
|
+
for rc in "$HOME_DIR/.zshrc" "$HOME_DIR/.zprofile" "$HOME_DIR/.profile" \
|
|
566
|
+
"$HOME_DIR/.bash_profile" "$HOME_DIR/.bashrc"; do
|
|
567
|
+
[ -f "$rc" ] || continue
|
|
568
|
+
if grep -q '\.local/bin/env' "$rc" 2>/dev/null; then
|
|
569
|
+
echo " edit strip uv env-shim line from $rc"
|
|
570
|
+
if [ "$DRY" -eq 0 ]; then
|
|
571
|
+
cp "$rc" "$rc.bak" 2>/dev/null || true
|
|
572
|
+
grep -v '\.local/bin/env' "$rc" > "$rc.tmp" 2>/dev/null && mv "$rc.tmp" "$rc"
|
|
573
|
+
fi
|
|
574
|
+
fi
|
|
575
|
+
done
|
|
535
576
|
echo " WARN --deep removed uv + ms-playwright; other tools on this machine that rely on them will re-provision."
|
|
536
577
|
else
|
|
537
578
|
echo " (left uv + ~/Library/Caches/ms-playwright + ~/.cache/uv in place)"
|
|
@@ -539,6 +580,47 @@ fi
|
|
|
539
580
|
fi
|
|
540
581
|
echo
|
|
541
582
|
|
|
583
|
+
# ---- 6b. DEEP: packaged Google Chrome + its Keystone updater ----------------
|
|
584
|
+
# The provisioner installs a REAL Google Chrome.app (runtime.ts Step 7) — the
|
|
585
|
+
# browser the scanner drives over CDP — that NO other reset step removes, plus
|
|
586
|
+
# installing it drops Google's Keystone auto-updater (LaunchAgents + support
|
|
587
|
+
# dirs). A bare box had none of this. --deep removes all of it so the Chrome
|
|
588
|
+
# install step is exercised fresh. NOTE: this touches the machine's Google Chrome
|
|
589
|
+
# globally, not just S4L — hence --deep / test-box only.
|
|
590
|
+
if [ "$PLUGIN_ONLY" -eq 1 ]; then
|
|
591
|
+
echo "[6b] packaged Google Chrome + Keystone updater — skipped (plugin reset; pass --deep to remove)"
|
|
592
|
+
else
|
|
593
|
+
echo "[6b] packaged Google Chrome + Keystone updater — $([ "$DEEP" -eq 1 ] && echo ENABLED || echo 'skipped (pass --deep)')"
|
|
594
|
+
if [ "$DEEP" -eq 1 ]; then
|
|
595
|
+
# Stop Chrome + unload the Keystone/updater agents before deleting their plists.
|
|
596
|
+
if [ "$DRY" -eq 0 ]; then
|
|
597
|
+
pkill -f 'Google Chrome' 2>/dev/null || true
|
|
598
|
+
for lbl in com.google.keystone.agent com.google.keystone.xpcservice com.google.GoogleUpdater.wake; do
|
|
599
|
+
launchctl bootout "gui/$(id -u)/$lbl" 2>/dev/null || true
|
|
600
|
+
done
|
|
601
|
+
fi
|
|
602
|
+
rm_path "/Applications/Google Chrome.app" "google-chrome"
|
|
603
|
+
rm_path "$HOME_DIR/Applications/Google Chrome.app" "google-chrome-user"
|
|
604
|
+
for p in \
|
|
605
|
+
"$HOME_DIR/Library/Application Support/Google" \
|
|
606
|
+
"$HOME_DIR/Library/Google" \
|
|
607
|
+
"$HOME_DIR/Library/Caches/Google" \
|
|
608
|
+
"$HOME_DIR/Library/Caches/com.google.Chrome" \
|
|
609
|
+
"$HOME_DIR/Library/Caches/com.google.Keystone" \
|
|
610
|
+
"$HOME_DIR/Library/LaunchAgents/com.google.keystone.agent.plist" \
|
|
611
|
+
"$HOME_DIR/Library/LaunchAgents/com.google.keystone.xpcservice.plist" \
|
|
612
|
+
"$HOME_DIR/Library/LaunchAgents/com.google.GoogleUpdater.wake.plist" \
|
|
613
|
+
"$HOME_DIR/Library/LaunchAgents/com.google.GoogleUpdater.plist"; do
|
|
614
|
+
rm_path "$p" "google"
|
|
615
|
+
done
|
|
616
|
+
for f in "$HOME_DIR"/Library/Preferences/com.google.Chrome*.plist; do
|
|
617
|
+
[ -e "$f" ] && rm_path "$f" "google-pref"
|
|
618
|
+
done
|
|
619
|
+
echo " WARN --deep removed Google Chrome + Keystone updater machine-wide."
|
|
620
|
+
fi
|
|
621
|
+
fi
|
|
622
|
+
echo
|
|
623
|
+
|
|
542
624
|
# ---- 7. settle & verify (defeat Claude auto-relaunch resurrection) ---------
|
|
543
625
|
# Claude Desktop can RELAUNCH after step [0] — on auto-update (ShipIt), a macOS
|
|
544
626
|
# re-open, or a login item — and a relaunched host re-materializes a skeleton
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Set the S4L release channel (stable or staging).
|
|
3
|
+
# Usage:
|
|
4
|
+
# bash scripts/set-channel.sh stable # track stable releases only (default)
|
|
5
|
+
# bash scripts/set-channel.sh staging # track staging releases (pre-releases)
|
|
6
|
+
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
if [ $# -lt 1 ]; then
|
|
10
|
+
echo "Usage: bash scripts/set-channel.sh <stable|staging>" >&2
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
CHANNEL="$1"
|
|
15
|
+
if [ "$CHANNEL" != "stable" ] && [ "$CHANNEL" != "staging" ]; then
|
|
16
|
+
echo "Error: channel must be 'stable' or 'staging'" >&2
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
STATE_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}"
|
|
21
|
+
mkdir -p "$STATE_DIR"
|
|
22
|
+
echo "{\"channel\":\"$CHANNEL\"}" > "$STATE_DIR/channel.json"
|
|
23
|
+
|
|
24
|
+
echo "✓ Release channel set to: $CHANNEL"
|
|
25
|
+
if [ "$CHANNEL" = "staging" ]; then
|
|
26
|
+
echo " Will now track pre-releases (rc.N builds). Next update check pulls the latest staging release."
|
|
27
|
+
else
|
|
28
|
+
echo " Will now track stable releases only. Next update check pulls the latest stable release."
|
|
29
|
+
fi
|
|
@@ -1027,17 +1027,50 @@ def cmd_connect(args) -> dict:
|
|
|
1027
1027
|
except Exception as e:
|
|
1028
1028
|
return {"ok": False, "connected": False, "state": "error", "error": str(e), "cdp": CDP}
|
|
1029
1029
|
|
|
1030
|
-
# 1b.
|
|
1031
|
-
#
|
|
1032
|
-
#
|
|
1033
|
-
#
|
|
1034
|
-
#
|
|
1035
|
-
#
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1030
|
+
# 1b. Choose the connection PATH before doing any keychain work. There are
|
|
1031
|
+
# two mutually exclusive paths, and only one of them touches the keychain:
|
|
1032
|
+
# - IMPORT: copy an existing x.com session out of the user's everyday
|
|
1033
|
+
# browser. This reads that browser's "Safe Storage" key from the macOS
|
|
1034
|
+
# Keychain, so it (and ONLY it) is gated by the headless keychain
|
|
1035
|
+
# preflight below.
|
|
1036
|
+
# - MANUAL LOGIN: open the managed Chrome's own login page and let the
|
|
1037
|
+
# user sign in by hand. Reads NO keychain and needs NO everyday-browser
|
|
1038
|
+
# session, so it must NEVER be blocked by the keychain preflight.
|
|
1039
|
+
# Auto-import is only possible when some installed browser actually holds
|
|
1040
|
+
# an x.com session. On a fresh or remote/headless box there is nothing to
|
|
1041
|
+
# import, so gating manual login behind the keychain check dead-ended setup
|
|
1042
|
+
# on "keychain locked" even though there was no session to copy anyway
|
|
1043
|
+
# (the exact wedge that stalled the macstadium test box). We now skip
|
|
1044
|
+
# straight to manual login whenever there's nothing to import.
|
|
1045
|
+
#
|
|
1046
|
+
# Source selection:
|
|
1047
|
+
# - explicit --source X -> just that one (one keychain prompt)
|
|
1048
|
+
# - --source all -> the full chrome/arc/brave/edge sweep (legacy)
|
|
1049
|
+
# - no --source (default) -> the browser(s) that ACTUALLY hold an x.com
|
|
1050
|
+
# session, so we prompt the keychain for exactly the right one.
|
|
1051
|
+
if args.source == "all":
|
|
1052
|
+
sources = AUTO_SOURCES
|
|
1053
|
+
has_importable = True
|
|
1054
|
+
elif args.source:
|
|
1055
|
+
sources = [args.source]
|
|
1056
|
+
has_importable = True
|
|
1057
|
+
else:
|
|
1058
|
+
_srcs = _list_sources()
|
|
1059
|
+
_with_session = [s["spec"] for s in _srcs if s.get("x_session")]
|
|
1060
|
+
sources = _with_session or ["chrome:Default"]
|
|
1061
|
+
has_importable = bool(_with_session)
|
|
1062
|
+
|
|
1063
|
+
manual_login = bool(getattr(args, "manual_login", False))
|
|
1064
|
+
# Attempt an auto-import ONLY when the user didn't force manual login AND
|
|
1065
|
+
# there is a real session to copy. Otherwise fall through to manual login.
|
|
1066
|
+
will_import = has_importable and not manual_login
|
|
1067
|
+
|
|
1068
|
+
# 1c. Headless + Keychain pre-flight (#3 + #4, added 2026-06-02) — relevant
|
|
1069
|
+
# ONLY to the import path. copy_browser_cookies.py must read the per-browser
|
|
1070
|
+
# Safe Storage entry from the OS keychain; SSH/launchd-invoked processes get
|
|
1071
|
+
# errSecAuthFailed silently (no prompt). The manual-login path skips this.
|
|
1072
|
+
if will_import and _is_headless():
|
|
1073
|
+
# Probe Chrome (the autoposter default); if that's denied, the rest are too.
|
|
1041
1074
|
kc_ok, kc_detail = _keychain_safe_storage_ok("Chrome")
|
|
1042
1075
|
if not kc_ok:
|
|
1043
1076
|
return {
|
|
@@ -1053,28 +1086,17 @@ def cmd_connect(args) -> dict:
|
|
|
1053
1086
|
"or another headless context). No GUI prompt is shown for this — macOS "
|
|
1054
1087
|
"denies access silently. To fix, run this once in the same session:\n"
|
|
1055
1088
|
" security unlock-keychain ~/Library/Keychains/login.keychain-db\n"
|
|
1056
|
-
"Then re-run connect_x.
|
|
1057
|
-
"
|
|
1058
|
-
"`security set-keychain-settings -lut 0`."
|
|
1089
|
+
"Then re-run connect_x. Or connect X with manual login instead — that "
|
|
1090
|
+
"signs in directly in the autoposter's own browser and needs no keychain."
|
|
1059
1091
|
),
|
|
1060
1092
|
"remediation_cmd": "security unlock-keychain ~/Library/Keychains/login.keychain-db",
|
|
1061
1093
|
"cdp": CDP,
|
|
1062
1094
|
}
|
|
1063
1095
|
|
|
1064
|
-
# 2. Import from the user's everyday browser
|
|
1065
|
-
# -
|
|
1066
|
-
# - --source all -> the full chrome/arc/brave/edge sweep (legacy)
|
|
1067
|
-
# - no --source (the default) -> auto-pick the browser(s) that ACTUALLY
|
|
1068
|
-
# hold an x.com session, so we prompt the keychain for exactly the right
|
|
1069
|
-
# one instead of blindly walking all four and prompting per browser.
|
|
1070
|
-
if args.source == "all":
|
|
1071
|
-
sources = AUTO_SOURCES
|
|
1072
|
-
elif args.source:
|
|
1073
|
-
sources = [args.source]
|
|
1074
|
-
else:
|
|
1075
|
-
sources = _auto_pick_sources()
|
|
1096
|
+
# 2. Import from the user's everyday browser (skipped entirely when we're on
|
|
1097
|
+
# the manual-login path — `will_import` False makes this loop a no-op).
|
|
1076
1098
|
attempts = []
|
|
1077
|
-
for src in sources:
|
|
1099
|
+
for src in (sources if will_import else []):
|
|
1078
1100
|
res = _import_from(src)
|
|
1079
1101
|
copied = res.get("stdout", "")
|
|
1080
1102
|
detail = copied or res.get("error") or res.get("stderr")
|
|
@@ -1139,8 +1161,15 @@ def cmd_connect(args) -> dict:
|
|
|
1139
1161
|
rolled_up_error_type = (
|
|
1140
1162
|
next(iter(distinct_error_types)) if len(distinct_error_types) == 1 else None
|
|
1141
1163
|
)
|
|
1142
|
-
|
|
1143
|
-
|
|
1164
|
+
# Open a focused login window when the user asked for manual login, when the
|
|
1165
|
+
# keychain prompt was denied (import can't proceed), OR when there was nothing
|
|
1166
|
+
# to import in the first place — in that last case manual login is the ONLY
|
|
1167
|
+
# path that can work, so we must open the window rather than dead-end.
|
|
1168
|
+
open_login = (
|
|
1169
|
+
manual_login
|
|
1170
|
+
or not will_import
|
|
1171
|
+
or rolled_up_error_type == "keychain_acl_denied"
|
|
1172
|
+
)
|
|
1144
1173
|
|
|
1145
1174
|
shown = False
|
|
1146
1175
|
if open_login:
|
|
@@ -1156,7 +1185,7 @@ def cmd_connect(args) -> dict:
|
|
|
1156
1185
|
# That race is what made setup misreport the handle as "missing." If the
|
|
1157
1186
|
# cookie appears, fall through to the same connected/persist/handle path the
|
|
1158
1187
|
# auto-import success branch uses.
|
|
1159
|
-
login_wait = getattr(args, "login_wait",
|
|
1188
|
+
login_wait = getattr(args, "login_wait", 300.0)
|
|
1160
1189
|
if login_wait and login_wait > 0 and _poll_for_login(timeout=login_wait):
|
|
1161
1190
|
try:
|
|
1162
1191
|
if _is_session_valid():
|
|
@@ -1197,16 +1226,23 @@ def cmd_connect(args) -> dict:
|
|
|
1197
1226
|
)
|
|
1198
1227
|
extra["remediation"] = "rerun_connect_x_and_click_allow"
|
|
1199
1228
|
elif open_login:
|
|
1200
|
-
#
|
|
1201
|
-
#
|
|
1229
|
+
# Manual login: either the caller asked for it (--manual-login) or there
|
|
1230
|
+
# was no existing X session anywhere to import, so this is the only path.
|
|
1231
|
+
# A focused login window is open and we waited; they just haven't finished
|
|
1232
|
+
# signing in yet.
|
|
1233
|
+
_why = (
|
|
1234
|
+
"" if will_import
|
|
1235
|
+
else "No existing X session was found in your everyday browser to import, so "
|
|
1236
|
+
)
|
|
1237
|
+
_tried = (" (Auto-import tried: " + ", ".join(sources) + ".)") if will_import else ""
|
|
1202
1238
|
note = (
|
|
1203
|
-
|
|
1239
|
+
_why
|
|
1240
|
+
+ "a Chrome window for the autoposter is open at the X login page"
|
|
1204
1241
|
+ ("" if shown else " (if you don't see it, look for a 'Google Chrome' window)")
|
|
1205
1242
|
+ " and you are NOT logged in yet. Log in there yourself — username, password, "
|
|
1206
1243
|
"and 2FA if prompted — in that window. When your X home timeline shows, ask me "
|
|
1207
1244
|
"to confirm and I'll re-check (run connect_x again). The session is saved to the "
|
|
1208
|
-
"autoposter's own profile, so this is a one-time step.
|
|
1209
|
-
"(Auto-import tried: " + ", ".join(sources) + ".)"
|
|
1245
|
+
"autoposter's own profile, so this is a one-time step." + _tried
|
|
1210
1246
|
)
|
|
1211
1247
|
else:
|
|
1212
1248
|
# Auto-import failed for a non-deny reason and the user did NOT ask for
|
|
@@ -1294,10 +1330,12 @@ def main() -> int:
|
|
|
1294
1330
|
"the login window only opens when the user DENIED the keychain "
|
|
1295
1331
|
"prompt; every other auto-import failure returns needs_login "
|
|
1296
1332
|
"without popping an unexpected browser window.")
|
|
1297
|
-
c.add_argument("--login-wait", type=float, default=
|
|
1333
|
+
c.add_argument("--login-wait", type=float, default=300.0,
|
|
1298
1334
|
help="Seconds to wait for a MANUAL login to complete before "
|
|
1299
|
-
"returning needs_login (default
|
|
1300
|
-
"
|
|
1335
|
+
"returning needs_login (default 300; 0 disables the wait). "
|
|
1336
|
+
"A human finding the on-screen/VNC window plus password + 2FA "
|
|
1337
|
+
"routinely exceeds 90s, so the wait is generous. Prevents the "
|
|
1338
|
+
"detection race that misreports the handle as missing.")
|
|
1301
1339
|
args = ap.parse_args()
|
|
1302
1340
|
|
|
1303
1341
|
if args.cmd == "detect-sources":
|