@m13v/s4l 1.7.1-rc.7 → 1.7.1-rc.9

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 CHANGED
@@ -823,6 +823,29 @@ function installMcp() {
823
823
  } catch (e) {
824
824
  console.warn(' WARNING: could not stamp MCP version:', e && e.message);
825
825
  }
826
+ // Auto-opt a fresh install into the staging channel when the version being
827
+ // installed is itself a pre-release (-rc.N) — e.g. `npx social-autoposter@
828
+ // X.Y.Z-rc.N init`. Without this, channel.json stays absent, which
829
+ // scripts/s4l_channel.py's fail-safe default reads as "stable", so the box
830
+ // would install this one rc and then silently stop tracking staging (never
831
+ // pick up the next rc). Mirrors the same one-time write in
832
+ // mcp/src/runtime.ts's provision() for the .mcpb (Desktop) install path.
833
+ // Only writes when no channel marker exists yet — never overrides an
834
+ // existing preference (e.g. a user who already opted back out).
835
+ try {
836
+ const pkgVersion = require('../package.json').version;
837
+ if (pkgVersion.includes('-rc.')) {
838
+ const stateDir = process.env.S4L_STATE_DIR || path.join(os.homedir(), '.social-autoposter-mcp');
839
+ const channelPath = path.join(stateDir, 'channel.json');
840
+ if (!fs.existsSync(channelPath)) {
841
+ fs.mkdirSync(stateDir, { recursive: true });
842
+ fs.writeFileSync(channelPath, JSON.stringify({ channel: 'staging' }, null, 2) + '\n');
843
+ console.log(' pre-release install detected -> opted into the staging channel');
844
+ }
845
+ }
846
+ } catch (e) {
847
+ console.warn(' WARNING: could not set staging channel:', e && e.message);
848
+ }
826
849
  console.log(' installing MCP runtime deps (npm install --omit=dev in mcp/)');
827
850
  const npmRes = spawnSync('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], {
828
851
  cwd: mcpDest,
@@ -676,6 +676,29 @@ async function provision(progress) {
676
676
  /* best effort; S4L_REPO_DIR + the run-*.sh fallback also resolve the repo */
677
677
  }
678
678
  setStep("repo", "done", `unpacked to ${resolvedRepo}`);
679
+ // Auto-opt a bare .mcpb FIRST install into the staging channel when the
680
+ // shipped build is itself a pre-release (-rc.N) — e.g. downloaded from
681
+ // the staging-latest alias link (scripts/release-mcpb.sh step 7b).
682
+ // Without this, channel.json stays absent, which releaseChannel()'s
683
+ // fail-safe default reads as "stable" (mcp/src/version.ts), so the box
684
+ // would install this one rc and then silently stop tracking staging
685
+ // (never pick up the next rc). Mirrors the same one-time write in
686
+ // bin/cli.js's installMcp() for the npx install path. Gated to this
687
+ // `else` branch (genuine bare-.mcpb unpack, not envClone) so a
688
+ // developer's own npm/git clone is never auto-enrolled. Only writes when
689
+ // no channel marker exists yet — never overrides an existing preference.
690
+ try {
691
+ const bv = bundledVersion();
692
+ const channelPath = path.join(STATE_DIR, "channel.json");
693
+ if (bv && bv.includes("-rc.") && !fs.existsSync(channelPath)) {
694
+ fs.mkdirSync(STATE_DIR, { recursive: true });
695
+ fs.writeFileSync(channelPath, JSON.stringify({ channel: "staging" }, null, 2) + "\n", "utf-8");
696
+ console.error(`[runtime] first install of a staging build (${bv}) — opted into the staging channel`);
697
+ }
698
+ }
699
+ catch {
700
+ /* best effort; worst case the box just tracks stable */
701
+ }
679
702
  }
680
703
  // --- Step 1: uv -----------------------------------------------------------
681
704
  setStep("uv", "running");
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.1-rc.7",
3
- "installedAt": "2026-07-07T23:08:38.967Z"
2
+ "version": "1.7.1-rc.9",
3
+ "installedAt": "2026-07-08T00:22:25.267Z"
4
4
  }
package/mcp/install.mjs CHANGED
@@ -14,8 +14,6 @@ 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");
19
17
 
20
18
  // ---- resolve the absolute paths we want pinned into the spawn env ----------
21
19
  const here = path.dirname(new URL(import.meta.url).pathname);
@@ -137,19 +135,6 @@ for (const t of targets) {
137
135
  }
138
136
  }
139
137
 
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
-
153
138
  console.log("");
154
139
  if (ok === targets.length) {
155
140
  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.7",
5
+ "version": "1.7.1-rc.9",
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": {
@@ -132,20 +132,24 @@ except Exception as _import_err:
132
132
  import s4l_state as st # noqa: E402
133
133
 
134
134
  # AppKit is available in the owned venv (PyObjC is a rumps dependency). We use it
135
- # only to pull the accessory (LSUIElement) app to the front before showing an
136
- # NSAlert: an agent app that isn't the active app has its rumps.alert appear
137
- # BEHIND the frontmost window ("modal doesn't show on top"), because runModal
138
- # doesn't activate the app for us. Guarded so a missing AppKit never breaks the
139
- # menu bar — the alert still shows, just possibly not front-most.
135
+ # to pull the accessory (LSUIElement) app to the front AND to force the alert
136
+ # window itself above whatever else is on screen. Guarded so a missing AppKit
137
+ # never breaks the menu bar — the alert still shows, just possibly not
138
+ # front-most.
140
139
  try:
141
- from AppKit import NSApplication # noqa: E402
140
+ from AppKit import NSApplication, NSAlert, NSFloatingWindowLevel # noqa: E402
142
141
  except Exception:
143
142
  NSApplication = None
143
+ NSAlert = None
144
+ NSFloatingWindowLevel = None
144
145
 
145
146
 
146
147
  def _activate_front():
147
- """Bring this accessory app to the front so the next NSAlert (rumps.alert)
148
- opens on top of whatever was frontmost, instead of behind it. Best-effort."""
148
+ """Bring this accessory app to the front so the next alert opens on top of
149
+ whatever was frontmost, instead of behind it. Best-effort; kept as a
150
+ belt-and-suspenders call alongside _show_alert's own window-level fix
151
+ below, since activation also affects which app is "key" for keyboard
152
+ input on the alert's buttons."""
149
153
  try:
150
154
  if NSApplication is not None:
151
155
  NSApplication.sharedApplication().activateIgnoringOtherApps_(True)
@@ -153,6 +157,44 @@ def _activate_front():
153
157
  pass
154
158
 
155
159
 
160
+ def _show_alert(title=None, message='', ok=None, cancel=None, other=None):
161
+ """Like rumps.alert(), but actually shows up on top.
162
+
163
+ rumps.alert() builds an NSAlert and immediately calls runModal() with no
164
+ window-level or ordering call at all — it just assumes the app is already
165
+ active. activateIgnoringOtherApps_ (_activate_front, above) is async, so
166
+ it can lose the race against runModal()'s own (assumed-active) window
167
+ presentation: the alert window is created at the default level and never
168
+ explicitly raised, so when this accessory app ISN'T active (e.g. Claude
169
+ Desktop is frontmost), it renders BEHIND Claude's window instead of on
170
+ top of it. Confirmed recurring 2026-07-07 (uninstall modal hidden behind
171
+ Claude Desktop) despite the activation call landing 2026-06-30.
172
+
173
+ Fix: build the NSAlert ourselves (same call rumps.alert uses) and force
174
+ its window to NSFloatingWindowLevel + orderFrontRegardless() BEFORE
175
+ runModal(), mirroring the pattern s4l_card.py's review panel already uses
176
+ successfully (see _ReviewPanel._build: setLevel_(NSFloatingWindowLevel) +
177
+ orderFrontRegardless()). Floating level sits above normal app windows in
178
+ the window server's global z-order regardless of app-activation state,
179
+ so it doesn't depend on the activation race resolving in time."""
180
+ _activate_front()
181
+ if NSAlert is None:
182
+ # AppKit unavailable in this venv: fall back to rumps' plain alert.
183
+ return rumps.alert(title=title, message=message, ok=ok, cancel=cancel, other=other)
184
+ msg = str(message).replace('%', '%%')
185
+ if not isinstance(cancel, str):
186
+ cancel = 'Cancel' if cancel else None
187
+ alert = NSAlert.alertWithMessageText_defaultButton_alternateButton_otherButton_informativeTextWithFormat_(
188
+ title, ok, cancel, other, msg)
189
+ alert.setAlertStyle_(0) # same style rumps.alert() uses
190
+ try:
191
+ alert.window().setLevel_(NSFloatingWindowLevel)
192
+ alert.window().orderFrontRegardless()
193
+ except Exception:
194
+ pass
195
+ return alert.runModal()
196
+
197
+
156
198
  CLAUDE_APP = "Claude"
157
199
 
158
200
 
@@ -595,7 +637,7 @@ class S4LMenuBar(rumps.App):
595
637
  msg = "Click OK, then open Claude and ask it to " + action_desc + "."
596
638
  try:
597
639
  _activate_front()
598
- rumps.alert(title=title, message=msg, ok="OK")
640
+ _show_alert(title=title, message=msg, ok="OK")
599
641
  except Exception:
600
642
  self._notify("S4L · prompt copied" if copied else "S4L",
601
643
  "Paste the prompt into Claude (Cmd+V) and press Enter.")
@@ -666,7 +708,7 @@ class S4LMenuBar(rumps.App):
666
708
  also run the registry self-heal, which additionally repairs the
667
709
  account-switch orphan case, so this one click covers both known causes."""
668
710
  _activate_front()
669
- choice = rumps.alert(
711
+ choice = _show_alert(
670
712
  title="Restart Claude Desktop?",
671
713
  message=(
672
714
  "Claude’s scheduler stopped running the draft tasks (a known "
@@ -994,7 +1036,7 @@ class S4LMenuBar(rumps.App):
994
1036
  # ok=1 (plugin reset, keeps X login + browser layer), other=-1 (deep wipe),
995
1037
  # cancel=0. See rumps.alert: default=1, alternate=0, other=-1.
996
1038
  _activate_front()
997
- choice = rumps.alert(
1039
+ choice = _show_alert(
998
1040
  title="Uninstall S4L?",
999
1041
  message=(
1000
1042
  "This quits Claude Desktop, removes the S4L extension + its "
@@ -1037,7 +1079,7 @@ class S4LMenuBar(rumps.App):
1037
1079
  def _alert(self, title, message):
1038
1080
  try:
1039
1081
  _activate_front()
1040
- rumps.alert(title=title, message=message, ok="OK")
1082
+ _show_alert(title=title, message=message, ok="OK")
1041
1083
  except Exception:
1042
1084
  pass
1043
1085
 
@@ -1072,7 +1114,7 @@ class S4LMenuBar(rumps.App):
1072
1114
  modal FIRST that Claude Desktop will restart, since the app window will
1073
1115
  close and reopen under them."""
1074
1116
  _activate_front()
1075
- choice = rumps.alert(
1117
+ choice = _show_alert(
1076
1118
  title="Quit the S4L autoposter?",
1077
1119
  message=(
1078
1120
  "Quitting stops the autoposter completely: the draft + query "
@@ -1758,16 +1800,14 @@ class S4LMenuBar(rumps.App):
1758
1800
  self._cwd_healed = True
1759
1801
  return
1760
1802
  _activate_front()
1761
- choice = rumps.alert(
1762
- title="Tidy the S4L background tasks?",
1803
+ choice = _show_alert(
1804
+ title="Tidy S4L background tasks?",
1763
1805
  message=(
1764
- "S4L can tidy its background tasks: merge the old draft + query "
1765
- "tasks into ONE universal worker (s4l-worker) and make sure "
1766
- "their once-a-minute runs stay in a dedicated folder instead of "
1767
- "cluttering your `claude --resume` history.\n\n"
1768
- "Claude Desktop will restart once to apply — its window will "
1769
- "close and reopen in a moment. Your X login, drafts, and config "
1770
- "all stay."
1806
+ "Move the background worker to a dedicated folder so its "
1807
+ "once-a-minute runs stop cluttering your `claude --resume` "
1808
+ "history.\n\n"
1809
+ "Claude Desktop will restart once to apply. Your X login, "
1810
+ "drafts, and config all stay."
1771
1811
  ),
1772
1812
  ok="Tidy & restart Claude", cancel="Later",
1773
1813
  )
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.1-rc.7",
3
+ "version": "1.7.1-rc.9",
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",
@@ -149,6 +149,14 @@ function runDoctorSync(options = {}) {
149
149
  "deferred until X connection to avoid an unexpected keychain prompt"
150
150
  );
151
151
  }
152
+ // Once the durable cookie mirror is populated, the X session no longer
153
+ // depends on this keychain item (see twitter_cookie_mirror.py — it exists
154
+ // precisely so a locked/missing Safe Storage key isn't fatal). Skip the
155
+ // probe instead of re-triggering + re-failing it on every doctor run;
156
+ // same mirrorCount() gate keychain_autolock already uses below.
157
+ if (mirrorCount() > 0) {
158
+ return result("pass", "skipped — durable cookie mirror already covers this");
159
+ }
152
160
  const r = spawnSync(
153
161
  "security",
154
162
  [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.1-rc.7",
3
+ "version": "1.7.1-rc.9",
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",
@@ -1,67 +1,50 @@
1
1
  #!/bin/bash
2
- # Get the permanent download URL for the latest S4L staging release (.mcpb).
2
+ # Print (or download) the permanent download link for the latest S4L staging
3
+ # release (.mcpb).
4
+ #
3
5
  # Usage:
4
- # bash scripts/get-latest-staging-mcpb.sh [--download] [--channel] [--path]
6
+ # bash scripts/get-latest-staging-mcpb.sh [--download] [--channel]
5
7
  #
6
8
  # 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)
9
+ # --download Download the .mcpb to the current directory (instead of
10
+ # just printing the URL)
11
+ # --channel After downloading, opt this machine into the staging
12
+ # channel (delegates to scripts/s4l_channel.py, the single
13
+ # source of truth for the channel marker)
14
+ #
15
+ # The link below is a FIXED URL, not a live GitHub API lookup: every
16
+ # `bash scripts/release-mcpb.sh --staging` run mirrors its .mcpb onto a
17
+ # separate, non-version-tagged release ("staging-latest") so this exact URL
18
+ # always serves whatever's newest — see scripts/release-mcpb.sh step 7b. That
19
+ # means this script needs zero network calls to resolve anything; it only
20
+ # hits the network when actually downloading.
10
21
 
11
22
  set -euo pipefail
12
23
 
24
+ STAGING_MCPB_URL="https://github.com/m13v/s4l/releases/download/staging-latest/social-autoposter.mcpb"
25
+
13
26
  DO_DOWNLOAD=0
14
27
  DO_CHANNEL=0
15
- DO_PATH=0
16
28
 
17
29
  while [[ $# -gt 0 ]]; do
18
30
  case "$1" in
19
31
  --download) DO_DOWNLOAD=1; shift ;;
20
32
  --channel) DO_CHANNEL=1; shift ;;
21
- --path) DO_PATH=1; shift ;;
22
- *) echo "Unknown option: $1"; exit 1 ;;
33
+ *) echo "Unknown option: $1" >&2; exit 1 ;;
23
34
  esac
24
35
  done
25
36
 
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"
37
+ if [ "$DO_DOWNLOAD" -eq 0 ]; then
38
+ echo "$STAGING_MCPB_URL"
49
39
  exit 0
50
40
  fi
51
41
 
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"
42
+ FILENAME="social-autoposter-staging.mcpb"
43
+ echo "Downloading $FILENAME from $STAGING_MCPB_URL" >&2
44
+ curl -fL "$STAGING_MCPB_URL" -o "$FILENAME"
45
+ echo "$FILENAME"
57
46
 
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"
47
+ if [ "$DO_CHANNEL" -eq 1 ]; then
48
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
49
+ python3 "$REPO_ROOT/scripts/s4l_channel.py" set staging >&2
67
50
  fi
@@ -45,6 +45,16 @@
45
45
  # the EXACT tested artifact ships to everyone. Nothing can drift between test and
46
46
  # ship because there is no repack.
47
47
  #
48
+ # FIXED STAGING DOWNLOAD LINK (2026-07-07, step 7b): stable has a permanent
49
+ # "latest" URL (releases/latest/download/social-autoposter.mcpb); staging did
50
+ # not, because GitHub's releases/latest hard-excludes prereleases with no
51
+ # override. Every --staging run now also mirrors the .mcpb onto a separate,
52
+ # non-version-tagged release ("staging-latest") so this URL never changes:
53
+ # https://github.com/m13v/s4l/releases/download/staging-latest/social-autoposter.mcpb
54
+ # The tag deliberately isn't semver-shaped, so version.ts/snapshot.py's real
55
+ # "newest rc" resolution (which requires a ^\d+\.\d+\.\d+ tag) ignores it —
56
+ # it is a convenience download link only, never a candidate update source.
57
+ #
48
58
  # Usage:
49
59
  # bash scripts/release-mcpb.sh # patch bump, npm + .mcpb + GitHub (STABLE)
50
60
  # bash scripts/release-mcpb.sh --bump minor
@@ -68,6 +78,12 @@ MCP_DIR="$REPO_ROOT/mcp"
68
78
  BUNDLE="$MCP_DIR/social-autoposter.mcpb"
69
79
  GH_REPO="m13v/s4l"
70
80
  SIZE_CAP_MB=180
81
+ # Floating alias release for a fixed "always the newest staging build" download
82
+ # link, mirroring what releases/latest/download/<asset> already gives stable.
83
+ # GitHub's own releases/latest excludes prereleases with no override, so there is
84
+ # no built-in equivalent for the rc channel — see step 7b below for why a
85
+ # non-semver tag here is safe (never confuses the real update-detection code).
86
+ STAGING_ALIAS_TAG="staging-latest"
71
87
 
72
88
  TAG_OVERRIDE=""
73
89
  VERSION_OVERRIDE=""
@@ -496,6 +512,38 @@ say "Released $TAG"
496
512
  echo " asset: social-autoposter.mcpb (${MB}MB)"
497
513
  [[ -n "$URL" ]] && echo " $URL"
498
514
 
515
+ # ---- 7b. Fixed "always latest staging" download link ------------------------
516
+ # Mirrors the asset just uploaded above onto a SEPARATE release tagged
517
+ # "$STAGING_ALIAS_TAG" (not a version), so the download URL never changes:
518
+ # https://github.com/$GH_REPO/releases/download/$STAGING_ALIAS_TAG/social-autoposter.mcpb
519
+ # Safe by construction: latestFromGithubListStaging() (mcp/src/version.ts) and
520
+ # _latest_from_github_list_staging() (scripts/snapshot.py) both require
521
+ # tag_name to match ^\d+\.\d+\.\d+ before treating a release as a candidate
522
+ # "newest" build; a tag literally named "staging-latest" fails that check and
523
+ # is silently skipped by both, so this alias can never get mistaken for a real
524
+ # rc and can never confuse which build a box installs. It exists ONLY to give
525
+ # humans (and scripts) one fixed bookmark — actual boxes still resolve "newest"
526
+ # from the real per-release rc tags via the releases LIST endpoint, unchanged.
527
+ if [[ "$DO_STAGING" == "1" ]]; then
528
+ say "Mirroring asset onto the fixed staging-latest alias"
529
+ ALIAS_NOTES="Always mirrors the newest staging pre-release's .mcpb asset (currently $TAG).
530
+
531
+ Fixed download link (never changes): https://github.com/$GH_REPO/releases/download/$STAGING_ALIAS_TAG/social-autoposter.mcpb
532
+
533
+ Not a real release — do not install from the tag page; use the asset link above. Actual per-build releases are tagged by version (e.g. $TAG)."
534
+ if gh release view "$STAGING_ALIAS_TAG" -R "$GH_REPO" >/dev/null 2>&1; then
535
+ gh release upload "$STAGING_ALIAS_TAG" "$BUNDLE" -R "$GH_REPO" --clobber
536
+ gh release edit "$STAGING_ALIAS_TAG" -R "$GH_REPO" --notes "$ALIAS_NOTES" >/dev/null
537
+ else
538
+ gh release create "$STAGING_ALIAS_TAG" "$BUNDLE" \
539
+ -R "$GH_REPO" \
540
+ --title "social-autoposter (staging — always latest)" \
541
+ --notes "$ALIAS_NOTES" \
542
+ --prerelease
543
+ fi
544
+ echo " https://github.com/$GH_REPO/releases/download/$STAGING_ALIAS_TAG/social-autoposter.mcpb"
545
+ fi
546
+
499
547
  # ---- 8. Verify the update banner will fire ---------------------------------
500
548
  # The menu-bar "⬆ Update available" banner (mcp/src/version.ts::versionStatus)
501
549
  # resolves "latest" from GitHub releases/latest, which is what .mcpb boxes (no
@@ -1,29 +0,0 @@
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