@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 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
- const snapshot = runtimeSnapshot();
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
- blockOnboardingMilestone("runtime_ready", "runtime_install_failed", snapshot.progress.error || "Runtime installation failed", { outcome: "failed" });
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,
@@ -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
- const r = await sh(uv, ["venv", "--python", PYTHON_VERSION, VENV_DIR], {
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 });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.1-rc.4",
3
- "installedAt": "2026-07-07T21:09:29.839Z"
2
+ "version": "1.7.1-rc.6",
3
+ "installedAt": "2026-07-07T21:25:16.877Z"
4
4
  }
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.4",
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.4",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.1-rc.4",
3
+ "version": "1.7.1-rc.6",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -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, and the packaged toolchain (uv binary, uv
21
- # cache, Chromium ~1.7G). Other tools that rely on uv/Chromium
22
- # re-provision afterward. Use to reproduce a true bare-metal box.
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. Headless + Keychain pre-flight (#3 + #4, added 2026-06-02).
1031
- # On macOS, copy_browser_cookies.py needs to read the per-browser Safe
1032
- # Storage entry from the OS keychain. SSH-invoked processes get
1033
- # errSecAuthFailed silently — no prompt, no warning. We probe up front so
1034
- # the user sees "your keychain is locked / run unlock-keychain" instead of
1035
- # the misleading "log in manually" cascade.
1036
- headless = _is_headless()
1037
- if headless:
1038
- # Probe with the first source's likely browser label. We don't know
1039
- # which source will succeed yet, so probe Chrome (the autoposter
1040
- # default); if that's denied, all the AUTO_SOURCES will be too.
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. If you're on the autoposter machine via SSH, you "
1057
- "may also need to run it before every fresh shell, or persist with "
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
- # - explicit --source X -> just that one (one keychain prompt)
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
- manual_login = bool(getattr(args, "manual_login", False))
1143
- open_login = manual_login or rolled_up_error_type == "keychain_acl_denied"
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", 90.0)
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
- # Explicit --manual-login: a window is open and we waited; they just
1201
- # haven't finished signing in yet.
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
- "A Chrome window for the autoposter is open at the X login page"
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=90.0,
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 90; 0 disables the wait). "
1300
- "Prevents the detection race that misreports the handle as missing.")
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":