social-autoposter 1.6.85 → 1.6.86

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 { 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, startLogStreaming, flushLogs } from "./telemetry.js";
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";
@@ -616,7 +616,6 @@ async function postApproved(batchId, plan) {
616
616
  });
617
617
  void flushSentry(2000);
618
618
  }
619
- void flushLogs();
620
619
  return {
621
620
  attempted: approved.length,
622
621
  posted: realPosted,
@@ -2384,11 +2383,6 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
2384
2383
  }));
2385
2384
  async function main() {
2386
2385
  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
2386
  // A plugin UPDATE refreshes this server (dist/) but not the materialized
2393
2387
  // pipeline. Re-extract the bundled pipeline.tgz when it's newer than what's on
2394
2388
  // disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
@@ -2439,7 +2433,6 @@ async function main() {
2439
2433
  main().catch(async (err) => {
2440
2434
  console.error("[social-autoposter-mcp] fatal:", err);
2441
2435
  captureError(err, { component: "main" });
2442
- await flushLogs();
2443
2436
  await flushSentry();
2444
2437
  process.exit(1);
2445
2438
  });
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) => {
@@ -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, setLineSink } from "./repo.js";
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
- }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.84",
3
- "installedAt": "2026-06-23T02:53:37.786Z"
2
+ "version": "1.6.86",
3
+ "installedAt": "2026-06-23T16:16:31.100Z"
4
4
  }
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.84",
5
+ "version": "1.6.86",
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.84",
3
+ "version": "1.6.86",
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": "social-autoposter",
3
- "version": "1.6.85",
3
+ "version": "1.6.86",
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,21 +1,35 @@
1
1
  #!/usr/bin/env bash
2
- # One-command release pipeline for the social-autoposter .mcpb (Story B double-click install).
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. Resolve the release version from the repo-root package.json (or --tag override).
6
- # 2. Build the MCP bundle: vite (panel) + tsc (server) + bundle-pipeline (embeds npm tarball).
7
- # 3. Stamp mcp/dist/version.json so the bundle self-reports the right version.
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 the bundle (size cap, embedded pipeline.tgz, stamped version, install tools).
10
- # 6. Create (or update) the GitHub release vX.Y.Z and upload the .mcpb as an asset.
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
- # Re-running for the same version is idempotent: the asset is re-uploaded with --clobber.
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 # version from package.json
16
- # bash scripts/release-mcpb.sh --tag v1.6.56
17
- # bash scripts/release-mcpb.sh --no-release # build + pack + verify only, skip GitHub
18
- # bash scripts/release-mcpb.sh --draft # create the GitHub release as a draft
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,30p' "$0"; exit 0 ;;
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 "$TAG_OVERRIDE" ]]; then
55
- TAG="$TAG_OVERRIDE"
56
- VERSION="${TAG#v}"
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
- say "Releasing social-autoposter .mcpb $TAG (package.json=$PKG_VERSION)"
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
- echo " note: tag version ($VERSION) differs from package.json ($PKG_VERSION)"
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. GitHub release ------------------------------------------------------
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