Skip to main content
Glama
bamr87

BASH-CMS

by bamr87
README.md
# BASH-CMS

Governed publishing from your repo, inside VS Code: browse what's worth sharing, draft LinkedIn company-page posts, run the brand guard, approve with your name on it, and publish — with an idempotency ledger so nothing ever double-posts.

Built by [BASH Consulting](https://bash-365.com) and used to run its own company page. The pipeline follows one rule end to end: **drafts are files, approval is a human decision recorded in the file, and a script does the mechanical work.** No post leaves your machine without a person deciding it should.

## How it works

```
Content ──▶ Draft (status: pending) ──▶ Approve (status: approved) ──▶ Publish ──▶ Ledger
   │              a file in                a person, in the             POST /rest/posts    .github/
 .cms/ index      drafts/linkedin/         review panel                 as the org          linkedin-log.json
 or a frontmatter scan
```

- **Content** comes from the `.cms/` contract when your repo has one (the [zer0-CMS](https://github.com/bamr87) content engine's machine-readable index), and degrades honestly to a frontmatter scan when it doesn't.
- **Drafts** are markdown files with `type` / `status` / `source` frontmatter — the same queue format the BASH CI lane publishes from, so the extension and a GitHub Actions workflow can share one queue and one ledger without stepping on each other.
- **The guard** is mechanical: LinkedIn's 3,000-character limit, the 140-character "…see more" fold, a banned-phrase list, filler detection, and your own patterns file. Errors block a live post; a human can still be overruled only by a human.
- **The ledger** is keyed by canonical URL. Either lane (extension or CI) posts first; the other skips. Byte-compatible with the Python lane's `json.dump` output, so the file never churns in git.

## The four views

| View | What it shows |
|---|---|
| **Drafts** | The queue, by status — open the review panel, approve, publish |
| **Content** | Distributable pages (health-ranked when `.cms/` exists), with a *posted* badge |
| **What to write next** | Catering lanes: A — distribute what exists · B — write more of what landed · C — say the quiet part · D — refresh what worked |
| **Published** | The ledger, newest first — jump to the live post |

## Getting started

1. Install the extension, open your content repo.
2. Set `bashCms.linkedin.orgUrn` and `bashCms.site.baseUrl` (Settings → BASH-CMS).
3. Run **BASH-CMS: Set LinkedIn Credentials** and paste a token with `w_organization_social` + `r_organization_social` (from the [token generator](https://www.linkedin.com/developers/tools/oauth/token-generator); requires Community Management API access on your LinkedIn app).
4. Run **BASH-CMS: LinkedIn: Who Am I** to confirm the token, scopes, and expiry.
5. Draft from the Content view, review, approve — and when you're ready to go live, enable `bashCms.publish.allow`.

Publishing is **off by default**. Until you enable it, every path — commands, the review panel, MCP — is preview and draft only.

## Commands

| Command | What it does |
|---|---|
| Set / Clear LinkedIn Credentials | Token (and optional client id/secret) into VS Code SecretStorage — never settings, never a file |
| LinkedIn: Who Am I | Token status, granted scopes, days to expiry |
| New Article Draft / New Text Draft | Stage a `status: pending` draft in the queue |
| Preview LinkedIn Payload | The exact `/rest/posts` body that would send — no network |
| Run Brand Guard on Draft | Errors, warnings, and the fold preview |
| Approve Draft | `pending → approved`, recorded in the file |
| Publish Draft… / Publish All Approved Drafts | The live path: guard → ledger → confirm → post → record |
| Open Review Panel | The approval gate, made legible (below) |
| Generate Catering Worklist | Write `.cms/distribution/worklists/<date>-catering.md` |
| Open Post on LinkedIn | Jump from the ledger to the live post |
| Configure MCP Server (.vscode/mcp.json) | Fallback wiring for older VS Code |

## The review panel

One panel per draft, opened beside the editor: the commentary with the **fold marker** where LinkedIn truncates, every guard finding, the exact outgoing JSON, and the ledger state. The Publish button stays disabled until the gates pass — and the extension re-checks every gate on its own side when you click, because a webview is UI, never the gate.

## MCP server

The extension registers a bundled MCP server with VS Code (1.101+), so Copilot agent mode — or any MCP client pointed at `dist/mcp-server.js` — gets six tools:

| Tool | Safe? |
|---|---|
| `linkedin_whoami` · `linkedin_list_posts` · `linkedin_get_post` · `linkedin_preview` | read-only |
| `linkedin_draft` | writes a pending draft for a human |
| `linkedin_publish` | **off by default** — needs `BASH_CMS_MCP_ALLOW_PUBLISH=1` in the server env *and* `confirm: true` per call |

The access token is injected into the server process only at start time, from SecretStorage — it never appears in a static definition, a setting, or a log.

## Settings

| Setting | Default | Purpose |
|---|---|---|
| `bashCms.linkedin.orgUrn` | — | The company page (`urn:li:organization:…`) |
| `bashCms.linkedin.apiVersion` | `202606` | `LinkedIn-Version` header (YYYYMM) |
| `bashCms.site.baseUrl` | — | Canonical site origin |
| `bashCms.site.permalinkPattern` | `/posts/:year/:month/:day/:slug/` | Canonical URL shape (the ledger key) |
| `bashCms.content.globs` | `pages/_posts/**/*.md` | Where content lives when there's no `.cms/` |
| `bashCms.content.previewsDir` / `.fallbackImage` | `assets/images/…` | Card thumbnail resolution |
| `bashCms.drafts.folder` | `drafts/linkedin` | The queue |
| `bashCms.ledger.path` | `.github/linkedin-log.json` | The shared ledger |
| `bashCms.publish.allow` | `false` | The master gate |
| `bashCms.publish.acceptStatuses` | `pending, approved` | Set to `approved` only to require the in-editor approval step |
| `bashCms.guard.bannedPatternsFile` | — | Extra banned patterns (`[{name, pattern, flags?}]`) |
| `bashCms.env.readDotEnv` | `true` | Read `LINKEDIN_*` from the workspace `.env` as a fallback |

## Boundaries

Four rules this tool is built around, in order of importance:

1. **Every post is approved by whoever's name goes on it.** There is no unattended path to the live page.
2. **Each account is authorized by its own owner's consent** — a token the owner minted, stored in SecretStorage.
3. **The audience you write for is declared by you** — never derived from connections, followers, or anyone's profile data.
4. **Everything read back is your own aggregate numbers.** No member-level data enters the catering lanes.

## Security notes

- Tokens live in VS Code SecretStorage (the OS keychain). The workspace `.env` is a read-only fallback for repos that already use one; the extension never writes secrets to disk.
- LinkedIn access tokens last 60 days and programmatic refresh is limited to LinkedIn partners — *Who Am I* tells you the days remaining, and the ledger records the expiry so a CI health-check can warn too.
- The HTTP client never puts the token in an error message or a log line.

## Development

```bash
npm install
npm run compile        # type-check + lint + both bundles
npm test               # unit + golden + MCP stdio + integration (downloads VS Code 1.101)
npx @vscode/vsce package   # build the .vsix
```

The test suite includes **golden cross-lane fixtures generated by the Python publishing lane** (`src/test/fixtures/golden/`) — the ledger bytes, payload previews, and catering worklists must match that lane byte-for-byte, because both write the same files in the same repos. Regenerate with `python3 src/test/fixtures/golden/generate.py <path-to-bashconsultants>`.

## License

[MIT](LICENSE) — © Amr Abdel-Motaleb, BASH Consulting.