From 7a9e2d69c29af0d2be191dc77a5bd6182e3435c9 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 7 Aug 2026 19:40:21 +0000 Subject: [PATCH 1/6] Add safe upgrades, prunable rules, and target validation; publish 0.3.0 Correctness - init --update refreshes only files that still match the install manifest, so template improvements can reach existing installs without touching edits. - install() now separates skipped (identical to template), stale (unedited but behind it), and customized (edited and preserved). - doctor treats frontend.mdc and debugging.mdc as prunable. Localization is told to delete them when they do not apply, and that no longer fails the check. - init, doctor, and detect validate the target directory. A mistyped multi-level --target is refused rather than created. - The installer ignores OS artifacts. A stray .DS_Store in template/ was copied into every install and failed three smoke checks on macOS, which CI could not observe from Ubuntu and Windows. - frontend.mdc declares globs in the comma-separated form Cursor documents; the previous YAML block sequence may not have parsed. - Version bumped to 0.3.0 so the published package matches the documented CLI. 0.2.0 shipped without the detect command the README describes. Demo - demo/hero.svg renders the comparison inline on GitHub via CSS keyframes. - demo/index.html adds playback controls and a chrome-free recording mode. - .github/workflows/pages.yml publishes demo/ to GitHub Pages. - README leads with the comparison, then a step-by-step npm quick start. Smoke suite at 194 checks, up from 159. --- .github/workflows/ci.yml | 11 + .github/workflows/pages.yml | 31 ++ .gitignore | 7 +- CHANGELOG.md | 35 +- README.md | 208 ++++++-- RELEASE_CHECKLIST.md | 10 +- SECURITY.md | 4 +- SUPPORT.md | 7 +- demo/hero.svg | 130 +++++ demo/index.html | 799 ++++++++++++++++++++++++++++ package.json | 2 +- scripts/init.mjs | 220 ++++++-- scripts/smoke-test.mjs | 158 +++++- template/.cursor/rules/frontend.mdc | 6 +- 14 files changed, 1536 insertions(+), 92 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 demo/hero.svg create mode 100644 demo/index.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c269bca..19bdb85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,17 @@ jobs: ./node_modules/.bin/cursor-os detect --target ./proj --format json | grep -q '"schemaVersion": 1' # bare invocation must print help and write nothing ./node_modules/.bin/cursor-os | grep -q "Usage:" + # the install manifest must ship so --update can tell stale from edited + test -f ./proj/.cursor/.cursor-os-manifest.json + # pruning an opt-in rule is a supported end state, not a broken install + rm ./proj/.cursor/rules/frontend.mdc + ./node_modules/.bin/cursor-os doctor --target ./proj | grep -q "pruned" + ./node_modules/.bin/cursor-os doctor --target ./proj + # a mistyped multi-level target must be refused, not fabricated + ! ./node_modules/.bin/cursor-os init --target ./no/such/tree + test ! -d ./no + # --update is a no-op preview on an unmodified install + ./node_modules/.bin/cursor-os init --update --dry-run --target ./proj # programmatic import must resolve node -e "import('cursor-os').then(m => { if (typeof m.install !== 'function' || typeof m.doctor !== 'function' || typeof m.detect !== 'function') process.exit(1); }).catch(() => process.exit(1))" diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..e014217 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,31 @@ +name: Deploy demo to GitHub Pages + +on: + push: + branches: [main] + paths: ["demo/**", ".github/workflows/pages.yml"] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: demo + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 8a3c2b7..e97ddbd 100644 --- a/.gitignore +++ b/.gitignore @@ -21,10 +21,15 @@ tmp/ # npm pack output *.tgz -# cursor-os installed on itself (dogfooded). The files below are byte-for-byte +# cursor-os installed on itself. The paths below are byte-for-byte copies of +# template/; tracking them would duplicate the source of truth and let the two +# drift. Recreate them in a fresh clone with: +# node scripts/init.mjs init --target . +# Until then, doctor reports them as missing on a fresh clone. .cursor/agents/ .cursor/skills/ .cursor/.cursor-os-version +.cursor/.cursor-os-manifest.json .cursor/rules/core.mdc .cursor/rules/debugging.mdc .cursor/rules/frontend.mdc diff --git a/CHANGELOG.md b/CHANGELOG.md index f8885a1..ffbc800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.3.0] — 2026-08-07 + ### Added - Read-only `detect` command with deterministic text and versioned JSON output. @@ -18,8 +20,37 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Detection warnings for malformed `package.json` and competing package-manager lockfiles; neither condition prevents a partial report. - Smoke coverage for programmatic and CLI detection, JSON parsing, read-only behavior, - invalid formats, stack presets, malformed manifests, and post-install signals (155 - checks total). + invalid formats, stack presets, malformed manifests, and post-install signals. +- `init --update`, which refreshes kit files that still match what a previous install + wrote and leaves edited files alone. Backed by `.cursor/.cursor-os-manifest.json`, + an install manifest recording a SHA-256 per file. Installs predating 0.3.0 have no + manifest, so every differing file is treated as edited until the next `init` writes + one. +- `install()` now classifies each file as `created`, `refreshed`, `skipped` (identical + to the template), `stale` (unedited but behind the template), `customized` (edited + and preserved), or `updated` (generated files). +- `doctor()` returns `missingRequired`, and each check carries an `optional` flag. +- Smoke suite expanded to 194 checks, covering pruned rules, target validation, update + semantics, and OS artifacts in `template/`. + +### Fixed + +- `doctor` no longer reports a broken install when localization prunes the opt-in rules + (`frontend.mdc`, `debugging.mdc`), which `prompts/localize-cursor-os.md` instructs it + to do. They are listed as `pruned` and no longer cause a non-zero exit. +- `init` no longer creates a directory tree from a mistyped `--target`. It creates at + most one new directory level and reports the missing parent otherwise. +- `doctor` and `detect` against a nonexistent directory now report that the directory is + missing rather than that Cursor OS is not installed. +- `init` against a regular file fails with a clear message instead of a raw `ENOTDIR`. +- The installer no longer treats OS artifacts (`.DS_Store`, `Thumbs.db`, `desktop.ini`) + as part of the kit. A stray `.DS_Store` in `template/` was copied into every install + and broke three count-based smoke checks on macOS, where CI could not observe it. +- `template/.cursor/rules/frontend.mdc` declared `globs` as a YAML block sequence. + Cursor's `.mdc` frontmatter is not strictly YAML and documents a comma-separated + list, so the rule may not have attached. Now `globs: **/*.tsx,**/*.jsx,...`. +- Corrected bin entry in `package.json` via `npm pkg fix` (the hyphenated key `cursor-os` + was auto-corrected during publish; this makes the source file match what npm holds). ### Changed diff --git a/README.md b/README.md index 90d7296..c179acc 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,136 @@ +
+ # Cursor OS -An installable operating layer that makes Cursor project-aware. +**An installable operating layer that makes Cursor project-aware.** + +Two side-by-side transcripts of the same feature request. Without Cursor OS, the model guesses at the project's router, data-access, and dependency conventions and claims completion without running checks. With Cursor OS, it reads the engineering contract first and reports verified command output. + +[Interactive demo](https://kingemma7.github.io/cursor-os/) · [Quick start](#quick-start) · [How it works](#how-cursor-os-works) · [CLI reference](#cli-reference) [![CI](https://github.com/KingEmma7/cursor-os/actions/workflows/ci.yml/badge.svg)](https://github.com/KingEmma7/cursor-os/actions/workflows/ci.yml) -[![Version](https://img.shields.io/github/package-json/v/KingEmma7/cursor-os)](package.json) +[![npm](https://img.shields.io/npm/v/cursor-os)](https://www.npmjs.com/package/cursor-os) +[![node](https://img.shields.io/node/v/cursor-os)](package.json) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +
+ +> The comparison above is illustrative. Each failure it shows is a pattern that occurs when a model works without project context, but the transcript is written, not recorded, and the counts are not measured. + > **Unofficial project.** Cursor OS is a community-maintained installable layer for Cursor. It is not affiliated with, endorsed by, or maintained by Cursor or Anysphere. +--- + +## Quick start + +Three commands and one paste. + +### 1. Install + +Run this from the root of the repository you want Cursor to understand. It works on a new +project or a mature codebase; the installer skips any file that already exists. + +```bash +cd /path/to/your-project +npx cursor-os init +``` + +Nothing is installed globally and no dependencies are added to your project. To preview the +file list without writing anything, add `--dry-run`. + +``` +Cursor OS v0.3.0 +Target: /path/to/your-project + +Created 20 file(s): + + .cursor/agents/verifier.md + + .cursor/rules/core.mdc + ... + + AGENTS.md + + docs/repo-memory.md + + prompts/localize-cursor-os.md + +Post-install check: + All files installed. 14 placeholder(s) await localization. + +Next: open Cursor in your-project and run prompts/localize-cursor-os.md +``` + +### 2. Review what the installer detected + +Optional. This reads your root manifests and reports the stack it can evidence, without +modifying anything. + +```bash +npx cursor-os detect +``` + +The JSON form feeds the localization step in the next section. + +```bash +npx cursor-os detect --format json +``` + +### 3. Localize it + +The installed files describe the shape of a project, not yours. Localization is what makes +them specific, and it runs once. + +Open your repository in Cursor, start an Agent chat, and paste the contents of +`prompts/localize-cursor-os.md`. + +```bash +pbcopy < prompts/localize-cursor-os.md # macOS +``` + +Cursor reads the codebase and fills in the real stack, commands, architecture, and +conventions. It deletes the rules that do not apply, and leaves a `TODO` wherever the +repository does not answer the question rather than guessing. + +With the Cursor CLI installed, this replaces the copy and paste: + +```bash +cursor-agent -p "$(cat prompts/localize-cursor-os.md)" +``` + +### 4. Verify + +```bash +npx cursor-os doctor +``` + +`Cursor OS appears installed and localized.` confirms the setup. From that point Cursor +loads `AGENTS.md` and your project memory on every request. + +### 5. Use the workflow prompts + +These are not loaded automatically. Paste one into chat when you want that workflow. + +| Prompt | When | +| --- | --- | +| `prompts/plan-feature.md` | Before writing code for a non-trivial feature | +| `prompts/implement-change.md` | When executing an agreed plan | +| `prompts/debug-regression.md` | When something is broken and the cause is unknown | +| `prompts/verify-work.md` | After a change is claimed complete | +| `prompts/review-pr.md` | Before merging | +| `prompts/update-repo-memory.md` | After a significant structural change | + +### Upgrading + +```bash +npx cursor-os init --update --dry-run # preview +npx cursor-os init --update +``` + +`--update` refreshes only the files that still match what the previous install wrote. +Anything you or localization edited is reported as customized and left in place. + +### Requirements + +Node.js 20 or newer. Cursor OS has no runtime dependencies. + +--- + ## The problem When you open a project in Cursor and ask it to build a feature, the model has no idea: @@ -56,39 +179,30 @@ It reports languages, frameworks, services, tooling, package scripts, workspace and applicable localization presets. It never edits the project, and its output is guidance—not a replacement for inspecting the actual code. -## The two-step setup +## Why localization is the step that matters -**Step 1 — Install the base OS** (the installer does this): +Installing gives you the structure. The files still contain `TODO` placeholders, so Cursor +reads them but learns nothing specific about your project. -```bash -cd /path/to/your-project -npx cursor-os init -``` - -This gives you the structure. The files contain TODO placeholders — Cursor knows to use them, but they don't yet describe your project. +Localization is what changes that. Cursor inspects the repository, replaces the +placeholders with facts it can verify from your code and configuration, tunes or deletes +the rules that do not fit, and creates `docs/architecture.md` where the project warrants +it. [`examples/localization-example.md`](examples/localization-example.md) walks through a +concrete before and after. -**Step 2 — Localize it** (you do this once in Cursor): - -Open Cursor in your project. Paste the contents of `prompts/localize-cursor-os.md` into the chat and send it. - -Cursor will inspect your repo, fill in the TODO markers with real facts (stack, commands, architecture, conventions), tune or remove irrelevant rules, and optionally create `docs/architecture.md` if the project warrants it. See [`examples/localization-example.md`](examples/localization-example.md) for a concrete before/after walkthrough. - -After localization, Cursor works with your project's actual context instead of guessing. +An unlocalized install provides very little. It is not an optional step. ## Designed for existing projects Cursor OS is designed to drop into any project at any stage — greenfield or mature codebase. The installer never overwrites existing files; it skips them and reports. -```bash -# From the root of any existing project -npx cursor-os init - -# Preview what would be installed first -npx cursor-os init --dry-run +### Upgrading an existing install -# Check if Cursor OS is already installed -npx cursor-os doctor -``` +`init` never overwrites a file you have edited. To make that a guarantee rather than a +heuristic, each install records a SHA-256 per file in `.cursor/.cursor-os-manifest.json`. +`init --update` refreshes a file only when its current contents still match what the +previous install wrote. Anything you or localization changed is reported as customized and +left in place for you to merge. Commit the manifest so the whole team upgrades identically. For new repos, create your project normally first, then run the installer from the project root. This repository's root is the Cursor OS source project, not the installed project layout. @@ -113,19 +227,23 @@ Example output: Cursor OS vX.Y.Z — doctor Target: /path/to/your-project - ok .cursor/agents/verifier.md - ok .cursor/rules/core.mdc - ok .cursor/skills/implementation-loop/SKILL.md - ok AGENTS.md + ok .cursor/agents/verifier.md + ok .cursor/rules/core.mdc + pruned .cursor/rules/frontend.mdc + note: optional rule — absent because localization pruned it, or never installed + ok .cursor/skills/implementation-loop/SKILL.md + ok AGENTS.md note: 4 TODO placeholder(s) remain — run prompts/localize-cursor-os.md - ok docs/quality-rubric.md - ok docs/repo-memory.md + ok docs/quality-rubric.md + ok docs/repo-memory.md note: 10 TODO placeholder(s) remain — run prompts/localize-cursor-os.md - ok prompts/localize-cursor-os.md - ok .cursor/.cursor-os-version + ok prompts/localize-cursor-os.md + ok .cursor/.cursor-os-version ``` -(Abbreviated — `doctor` lists every installed file; the version shown matches your checkout. The `note:` lines flag the unfilled TODO placeholders in `AGENTS.md` and `docs/repo-memory.md` that localization resolves. Once localization fills them, `doctor` reports "installed and localized". If the install came from an older Cursor OS version, `doctor` also notes the drift so you can re-run `init` to pick up new files.) +Abbreviated; `doctor` lists every installed file. The `note:` lines flag unfilled TODO placeholders in `AGENTS.md` and `docs/repo-memory.md`, which localization resolves. Once it does, `doctor` reports "installed and localized". + +Two behaviours are worth knowing. If the install came from an earlier version, `doctor` reports the drift, so you can run `init` for new files or `init --update` to also refresh unedited ones. And because localization is instructed to delete `frontend.mdc` and `debugging.mdc` when they do not apply, `doctor` lists those two as `pruned` rather than missing and does not fail. `init` runs this same health check automatically after installing, so you always see the placeholder count and the next step without a separate command. When root manifests expose recognizable tooling, it also prints a concise set of @@ -186,13 +304,18 @@ Commands: Options: -n, --dry-run Preview changes without writing anything (init only) + -u, --update Refresh kit files you never edited to the current version (init only) -t, --target DIR Use DIR as the target directory --format TYPE Output text or json (detect only; default: text) -v, --version Print version and exit -h, --help Show this help ``` -A command is required: bare invocation (`npx cursor-os` with no arguments) prints help and never writes files. For a target directory named `init`, `doctor`, or `detect`, or one whose name starts with `-`, use the intended command with `--target `. Requires Node.js 20 or newer. +A command is required. Bare invocation (`npx cursor-os` with no arguments) prints help and never writes files. + +The target directory must already exist, or be creatable as a single new level under an existing parent; a mistyped multi-level `--target` is rejected rather than created. For a target directory named `init`, `doctor`, or `detect`, or one whose name starts with `-`, use the intended command with `--target `. + +Requires Node.js 20 or newer. `detect` reads only root manifests, lockfiles, dependency names, and well-known config markers. JSON output uses a versioned schema and includes evidence for each signal plus @@ -256,6 +379,19 @@ prompts/ - `v0.3` — read-only project detection, deterministic JSON, and localization preset signals for Next.js, Supabase, and Vercel. 🚧 Unreleased - Next — opt-in interactive localization using the detected profile, with explicit review before edits. +## The demo + +The comparison at the top of this README is an animated SVG, so it plays on GitHub with +nothing to install. An interactive version lives in [`demo/`](demo/) and is published at +. It adds playback controls, a scrubber, speed +selection, and a chrome-free mode for screen recording. + +To run it locally, open `demo/index.html` in a browser. It is a single file with no build +step; the only external request is a webfont, and the page degrades cleanly without it. + +`demo/hero.svg` is plain SVG with CSS keyframes and no external references, which is what +allows GitHub to render it inline. Edit it directly to change the scenario. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). The bar for adding a file is high: it should improve agent behavior in a concrete way without duplicating an existing layer. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 3d4ca5c..fc5f052 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,6 +1,6 @@ # Release checklist -Use this checklist before tagging a public release or publishing Cursor OS to npm. +Use this checklist before tagging a release or publishing Cursor OS to npm. ## Repository @@ -66,3 +66,11 @@ Only after all previous sections pass: - [ ] Run `npm publish` only when intentionally publishing. - [ ] README install examples use the `npx cursor-os` form (done in the release commit, not after). - [ ] After publish, verify with `npm view cursor-os` and `npx cursor-os@latest init --dry-run --target `. + +## After publishing + +- [ ] `npm view cursor-os version` shows the expected version. +- [ ] `npx cursor-os --version` shows the expected version. +- [ ] `npx cursor-os init --dry-run --target ` works in a clean directory. +- [ ] GitHub Release notes for the tag link to npm and include `npx cursor-os init`. +- [ ] `main` has no undocumented post-tag changes that differ from what npm published (run `git log --oneline vX.Y.Z..main`; document any intentional drift under `[Unreleased]` in `CHANGELOG.md`). diff --git a/SECURITY.md b/SECURITY.md index 86fe6d3..058244d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,9 +4,7 @@ Cursor OS is an installable set of repo-local guidance, prompts, rules, and a de ## Supported versions -Until the first public release, only the current `main` branch is supported. - -After npm publishing begins, security fixes will target the latest released minor version unless otherwise stated in the changelog. +Security fixes target the latest released minor version on npm (`cursor-os`). The current `main` branch is also supported between releases. ## Reporting a vulnerability diff --git a/SUPPORT.md b/SUPPORT.md index c6a0526..d31bf08 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,6 +1,8 @@ # Support -Cursor OS is currently pre-npm and maintained as an open-source project. +Cursor OS is published on npm and maintained as an open-source project. + +Install: `npx cursor-os init` · Repo: https://github.com/KingEmma7/cursor-os ## Before opening an issue @@ -8,8 +10,7 @@ Cursor OS is currently pre-npm and maintained as an open-source project. 2. Run: ```bash - npm test - node scripts/init.mjs doctor --target /path/to/your-project + npx cursor-os doctor --target /path/to/your-project ``` 3. If Cursor OS is installed but still generic, run `prompts/localize-cursor-os.md` in Cursor. The base template is not project-aware until localization runs. diff --git a/demo/hero.svg b/demo/hero.svg new file mode 100644 index 0000000..34fe3df --- /dev/null +++ b/demo/hero.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + +DUAL-CHANNEL COMPARISON — ONE REQUEST, TWO SESSIONS +CURSOR WITHOUT CONTEXT vs. CURSOR OS +orbit — Next.js 15 · App Router · Supabase RLS +TASK Add a team-invite flow + + + + + + + + +CH A +WITHOUT CURSOR OS +context: 2 open files + + +CH B +WITH CURSOR OS +context: contract + memory + + + + +You Add a team-invite flow. +Agent I'll add an API route and a form component. +// pages/api/invite.ts +✗ Pages Router — removed from this repo 14 months ago +const supabase = createClient(URL, ANON_KEY) +✗ Bypasses the @supabase/ssr session layer +Agent Installing react-hot-toast for the success toast. +✗ Second toast library — sonner is already here +✗ invitations table created with RLS switched off +Agent Done — the invite flow should work. +✗ Zero checks executed before claiming completion +✗ Guessed teams.owner_id — the column is owner_user_id +You Add a team-invite flow. +Agent Reading AGENTS.md and docs/repo-memory.md first. +App Router · server actions · no API routes for writes +✓ app/(app)/team/actions.ts — server action +✓ client from lib/supabase/server.ts factory +✓ migration ships the RLS policy alongside the table +✓ reused sonner + existing zod schema · 0 new deps +$ pnpm typecheck · lint · test · e2e all passing +Agent Verified with commands and output. Remaining risk: +invite tokens have no TTL — logged to decision-log.md +6 corrections · 5 conventions missed · 0 checks run +0 corrections · 0 missed · 4 checks run · memory updated + +Transcripts are written, not recorded. Each failure shown is a real pattern; the counts are illustrative. +github.com/KingEmma7/cursor-os + diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..8048810 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,799 @@ + + + + + +Cursor OS — dual-channel comparison + + + + + + +
+ + +
+
+

Dual-channel comparison · Sheet 01

+

The same request,
with and without Cursor OS

+

Two sessions against the same Next.js + Supabase codebase, given the same request. Channel A has no project context. Channel B has the installed and localized engineering contract. The difference is what the model has to guess at.

+
+
+ REPO orbit — team analytics SaaS
+ STACK Next.js 15 · App Router
+ DATA Supabase · RLS enforced
+ UI Tailwind · shadcn/ui · sonner
+ TESTS Vitest · Playwright
+ TASK Add a team-invite flow +
+
+ +
+ + + Speed +
+ + + + +
+
+ + +
+ 0:00 / 0:00 + + +
+ +
+
+
+ CH A + Without Cursor OS + Cold session · context limited to open files +
+
Loaded context
+
+
+ +
+
+ CH B + With Cursor OS + Installed and localized · contract loads automatically +
+
Loaded context
+
+
+
+ +
+
+

Where the time went

+ Tallying — press play +
+ + + + + +
MeasureCH A — withoutCH B — with
+
+ +
+

+ About this comparison + The transcripts are written, not recorded. Channel A reproduces the failures Cursor OS is designed to prevent: the wrong router, a client constructed outside the session layer, a duplicate toast library, a table shipped without a row-level security policy, and "should work" standing in for verification. Each is a pattern that occurs when a model works without project context, but the counts shown here are illustrative rather than measured. +

+
+

Install it in your own repo

+ npx cursor-os init + + +
+
+
+ + + + + + diff --git a/package.json b/package.json index 7265009..d3a5e39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cursor-os", - "version": "0.2.0", + "version": "0.3.0", "description": "An installable operating layer that makes Cursor project-aware: a portable engineering contract, rules, memory docs, and prompts that adapt to any repository.", "type": "module", "bin": { diff --git a/scripts/init.mjs b/scripts/init.mjs index 30670ab..5717f86 100755 --- a/scripts/init.mjs +++ b/scripts/init.mjs @@ -13,9 +13,11 @@ import { readdirSync, lstatSync, realpathSync, + statSync, } from "node:fs"; +import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; -import { dirname, join, relative, resolve } from "node:path"; +import { dirname, join, relative, resolve, sep } from "node:path"; import { detect, formatDetectionText } from "./detect.mjs"; export { detect } from "./detect.mjs"; @@ -24,10 +26,74 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, ".."); const templateDir = join(repoRoot, "template"); const MARKER_REL = join(".cursor", ".cursor-os-version"); +const MANIFEST_REL = join(".cursor", ".cursor-os-manifest.json"); // Prose files doctor scans for unfilled placeholder markers. const TODO_FILES = ["AGENTS.md", join("docs", "repo-memory.md")]; +// OS and editor artifacts that must never be treated as part of the kit. Without +// this, a Finder visit to template/ adds .DS_Store to every install and breaks the +// count-based smoke checks on macOS only, where CI cannot see it. +const IGNORED_NAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".AppleDouble"]); + +// Opt-in rules that localization is instructed to delete when they don't apply +// (see prompts/localize-cursor-os.md, step 7). doctor reports these as pruned +// rather than missing, so a correctly localized project still passes. +const OPTIONAL_FILES = new Set([ + join(".cursor", "rules", "frontend.mdc"), + join(".cursor", "rules", "debugging.mdc"), +]); + +/** Stable manifest key, independent of the platform path separator. */ +function manifestKey(rel) { + return rel.split(sep).join("/"); +} + +function hashFile(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +/** Read the install manifest, or null when absent/unreadable (pre-0.3 installs). */ +function readManifest(target) { + try { + const parsed = JSON.parse(readFileSync(join(target, MANIFEST_REL), "utf8")); + return parsed && parsed.files && typeof parsed.files === "object" ? parsed : null; + } catch { + return null; + } +} + +/** + * Validate a target path. An existing target must be a directory. + * + * A missing target is acceptable only for init, and only one level below an + * existing parent: `cursor-os init my-project` keeps working, while a typo such + * as `--target ../projcts/app/web` is refused instead of silently creating the + * whole tree. doctor and detect always reject a missing target so the user sees + * "no such directory" rather than "not installed". + */ +function assertUsableTarget(target, { allowCreate = false } = {}) { + const resolved = resolve(target); + if (existsSync(resolved)) { + if (!statSync(resolved).isDirectory()) { + throw new Error(`target is not a directory: ${resolved}`); + } + return resolved; + } + if (!allowCreate) { + throw new Error( + `target directory does not exist: ${resolved}\n Check the path, or run init there first.`, + ); + } + const parent = dirname(resolved); + if (!existsSync(parent) || !statSync(parent).isDirectory()) { + throw new Error( + `target directory does not exist: ${resolved}\n Its parent (${parent}) does not exist either. Check the path for a typo;\n cursor-os creates at most one new directory level.`, + ); + } + return resolved; +} + function readVersion() { try { const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")); @@ -57,6 +123,7 @@ function parseArgs(argv) { const args = { command: null, dryRun: false, + update: false, format: "text", target: process.cwd(), help: false, @@ -72,6 +139,7 @@ function parseArgs(argv) { if (a === "--help" || a === "-h") { args.help = true; } else if (a === "--version" || a === "-v") { args.version = true; } else if (a === "--dry-run" || a === "-n") { args.dryRun = true; } + else if (a === "--update" || a === "-u") { args.update = true; } else if (a === "--format") { const value = argv[i + 1]; if (!value || value.startsWith("-")) { @@ -122,6 +190,10 @@ function parseArgs(argv) { args.errors.push("--dry-run is only valid with init"); } + if (args.command !== "init" && args.update) { + args.errors.push("--update is only valid with init"); + } + if (args.command !== "detect" && formatSet) { args.errors.push("--format is only valid with detect"); } @@ -145,6 +217,7 @@ Arguments: Options: -n, --dry-run Preview changes without writing anything (init only) + -u, --update Refresh kit files you never edited to the current version (init only) -t, --target DIR Use DIR as the target directory --format TYPE Output text or json (detect only; default: text) -v, --version Print version and exit @@ -153,6 +226,7 @@ Options: Examples: cursor-os init cursor-os init --dry-run + cursor-os init --update --dry-run cursor-os init --target ./my-project cursor-os doctor cursor-os doctor --target ./my-project @@ -161,12 +235,15 @@ Examples: Notes: A command is required; bare invocation prints this help and writes nothing. + The target directory must already exist. For a target directory named "init", "doctor" or "detect", or one starting with "-", use the intended command with the explicit form: --target . When running from a local checkout: node scripts/init.mjs The installer copies AGENTS.md, .cursor/, docs/, and prompts/ into the target. -It never overwrites existing user files — it skips them and reports. +It never overwrites a file you have edited. With --update it refreshes only the +files that still match what a previous install wrote; anything else is reported +as customized so you can merge it yourself. After installing, open Cursor and run prompts/localize-cursor-os.md.`; // ── File helpers ────────────────────────────────────────────────────────────── @@ -180,6 +257,7 @@ After installing, open Cursor and run prompts/localize-cursor-os.md.`; function listFiles(dir) { const out = []; for (const entry of readdirSync(dir)) { + if (IGNORED_NAMES.has(entry)) continue; const full = join(dir, entry); if (lstatSync(full).isDirectory()) { for (const child of listFiles(full)) out.push(join(entry, child)); @@ -197,44 +275,95 @@ function listFiles(dir) { * Returns { created, updated, skipped, target, dryRun, version }. * Never overwrites user files: any template path that already exists is skipped. */ -export function install({ target, dryRun = false } = {}) { +export function install({ target, dryRun = false, update = false } = {}) { if (!target) throw new Error("install() requires a target directory"); if (!existsSync(templateDir)) { throw new Error(`template directory not found at ${templateDir}`); } + assertUsableTarget(target, { allowCreate: true }); const version = readVersion(); - const result = { created: [], updated: [], skipped: [], target, dryRun, version }; + const result = { + created: [], + refreshed: [], + skipped: [], + stale: [], + customized: [], + updated: [], + target, + dryRun, + update, + version, + }; + + const priorManifest = readManifest(target); + const nextFiles = {}; - const write = (rel, writer) => { + for (const rel of listFiles(templateDir).sort()) { + const src = join(templateDir, rel); const dest = join(target, rel); - if (existsSync(dest)) { - result.skipped.push(rel); - return; + const templateHash = hashFile(src); + const key = manifestKey(rel); + + if (!existsSync(dest)) { + if (!dryRun) { + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(src, dest); + } + result.created.push(rel); + nextFiles[key] = templateHash; + continue; } - if (!dryRun) { - mkdirSync(dirname(dest), { recursive: true }); - writer(dest); + + const currentHash = hashFile(dest); + if (currentHash === templateHash) { + // Byte-identical to the shipped template; nothing to do. + result.skipped.push(rel); + nextFiles[key] = templateHash; + continue; } - result.created.push(rel); - }; - for (const rel of listFiles(templateDir).sort()) { - const src = join(templateDir, rel); - write(rel, (dest) => copyFileSync(src, dest)); + // The file differs from the template. The manifest tells us whether that is + // an edit worth preserving or drift from an older release worth refreshing. + const recordedHash = priorManifest?.files?.[key] ?? null; + const untouchedSinceInstall = recordedHash !== null && recordedHash === currentHash; + + if (update && untouchedSinceInstall) { + if (!dryRun) copyFileSync(src, dest); + result.refreshed.push(rel); + nextFiles[key] = templateHash; + } else { + // Never overwrite an edit. Without a manifest every difference is treated + // as an edit, which is the safe reading for installs predating 0.3.0. + if (untouchedSinceInstall) result.stale.push(rel); + else result.customized.push(rel); + nextFiles[key] = recordedHash ?? currentHash; + } } const markerDest = join(target, MARKER_REL); if (existsSync(markerDest)) { + if (!dryRun) writeFileSync(markerDest, `cursor-os ${version}\n`, "utf8"); + result.updated.push(MARKER_REL); + } else { if (!dryRun) { + mkdirSync(dirname(markerDest), { recursive: true }); writeFileSync(markerDest, `cursor-os ${version}\n`, "utf8"); } - result.updated.push(MARKER_REL); - } else { - write(MARKER_REL, (dest) => - writeFileSync(dest, `cursor-os ${version}\n`, "utf8"), + result.created.push(MARKER_REL); + } + + const manifestDest = join(target, MANIFEST_REL); + const manifestExisted = existsSync(manifestDest); + if (!dryRun) { + mkdirSync(dirname(manifestDest), { recursive: true }); + writeFileSync( + manifestDest, + `${JSON.stringify({ schemaVersion: 1, version, files: nextFiles }, null, 2)}\n`, + "utf8", ); } + (manifestExisted ? result.updated : result.created).push(MANIFEST_REL); return result; } @@ -269,10 +398,12 @@ function readMarkerVersion(target) { */ export function doctor({ target } = {}) { if (!target) throw new Error("doctor() requires a target directory"); + assertUsableTarget(target); const checks = doctorChecks().map(({ rel, label }) => { const fullPath = join(target, rel); const present = existsSync(fullPath); + const optional = OPTIONAL_FILES.has(rel); let note = null; let todoCount = 0; @@ -287,11 +418,22 @@ export function doctor({ target } = {}) { } } - return { label, present, note, todoCount }; + if (!present && optional) { + note = "optional rule — absent because localization pruned it, or never installed"; + } + + return { label, present, optional, note, todoCount }; }); const todoCount = checks.reduce((n, c) => n + c.todoCount, 0); - return { checks, todoCount, markerVersion: readMarkerVersion(target), target }; + const missingRequired = checks.filter((c) => !c.present && !c.optional).length; + return { + checks, + todoCount, + missingRequired, + markerVersion: readMarkerVersion(target), + target, + }; } // ── CLI entry point ─────────────────────────────────────────────────────────── @@ -306,10 +448,25 @@ function runInit(args) { console.log(`${verb} ${result.created.length} file(s):`); for (const f of result.created) console.log(` + ${f}`); } + if (result.refreshed.length) { + const refreshVerb = args.dryRun ? "Would refresh" : "Refreshed"; + console.log(`\n${refreshVerb} ${result.refreshed.length} unedited file(s):`); + for (const f of result.refreshed) console.log(` ^ ${f}`); + } if (result.skipped.length) { - console.log(`\nSkipped ${result.skipped.length} existing file(s):`); + console.log(`\nSkipped ${result.skipped.length} up-to-date file(s):`); for (const f of result.skipped) console.log(` = ${f}`); } + if (result.stale.length) { + console.log(`\n${result.stale.length} unedited file(s) are behind the current template:`); + for (const f of result.stale) console.log(` ! ${f}`); + console.log(" Run init --update to refresh them."); + } + if (result.customized.length) { + console.log(`\nKept ${result.customized.length} edited file(s):`); + for (const f of result.customized) console.log(` * ${f}`); + console.log(" These differ from the current template. Merge by hand if you want the new version."); + } if (result.updated.length) { const updateVerb = args.dryRun ? "Would refresh" : "Refreshed"; console.log(`\n${updateVerb} ${result.updated.length} generated file(s):`); @@ -324,7 +481,7 @@ function runInit(args) { // Post-install health check: confirm the install and surface what // localization still needs to fill in, so the next step is unmissable. const health = doctor({ target: args.target }); - const missing = health.checks.filter((c) => !c.present).length; + const missing = health.missingRequired; // Show a relative path only when the target is inside this checkout. const rel = relative(repoRoot, args.target); let where = rel || "this repo"; @@ -366,26 +523,25 @@ function runDoctor(args) { console.log(`Cursor OS v${version} — doctor`); console.log(`Target: ${args.target}\n`); - let allPresent = true; - for (const { label, present, note } of result.checks) { - const symbol = present ? "ok " : "MISSING"; + for (const { label, present, optional, note } of result.checks) { + const symbol = present ? "ok " : optional ? "pruned " : "MISSING"; console.log(` ${symbol} ${label}`); if (note) console.log(` note: ${note}`); - if (!present) allPresent = false; } if (result.markerVersion && result.markerVersion !== version) { console.log(`\n note: installed from cursor-os ${result.markerVersion}; current is ${version}.`); - console.log(" Re-run init to add any files introduced since (existing files are never overwritten)."); + console.log(" Re-run init to add files introduced since, or init --update to also"); + console.log(" refresh kit files you have not edited."); } console.log(""); - if (allPresent && result.todoCount === 0) { + if (result.missingRequired === 0 && result.todoCount === 0) { console.log("Cursor OS appears installed and localized."); - } else if (allPresent) { + } else if (result.missingRequired === 0) { console.log("Cursor OS is installed. Run prompts/localize-cursor-os.md to complete setup."); } else { - console.log("Cursor OS is not fully installed. Run: cursor-os init"); + console.log(`Cursor OS is not fully installed (${result.missingRequired} required file(s) missing). Run: cursor-os init`); process.exitCode = 1; } } diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index df73872..f597905 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -19,6 +19,7 @@ import { import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; const scriptPath = fileURLToPath(new URL("./init.mjs", import.meta.url)); @@ -111,8 +112,15 @@ withTempDir((dir) => { ), ); check("reports created files", result.created.length >= EXPECTED.length); - check("reports no refreshed files on a clean install", result.updated.length === 0); + check("clean install refreshes nothing", result.updated.length === 0); check("reports nothing skipped on a clean install", result.skipped.length === 0); + check("reports nothing stale on a clean install", result.stale.length === 0); + check("reports nothing customized on a clean install", result.customized.length === 0); + check("writes an install manifest", existsSync(join(dir, ".cursor", ".cursor-os-manifest.json"))); + check( + "counts the manifest as created, not refreshed", + result.created.includes(join(".cursor", ".cursor-os-manifest.json")), + ); }); // 2. --dry-run writes nothing. @@ -120,7 +128,7 @@ console.log("\ndry run:"); withTempDir((dir) => { const result = install({ target: dir, dryRun: true }); check("dry run reports files it would create", result.created.length > 0); - check("dry run reports no refreshed files in an empty dir", result.updated.length === 0); + check("dry run refreshes nothing in an empty dir", result.updated.length === 0); check("dry run writes zero files to disk", listAll(dir).length === 0); }); @@ -135,7 +143,7 @@ withTempDir((dir) => { const result = install({ target: dir, dryRun: true }); check( "dry run reports existing marker would refresh", - result.updated.length === 1 && result.updated[0] === join(".cursor", ".cursor-os-version"), + result.updated.includes(join(".cursor", ".cursor-os-version")), ); check( "dry run preserves existing marker byte-for-byte", @@ -155,19 +163,24 @@ withTempDir((dir) => { "preserves a pre-existing AGENTS.md byte-for-byte", readFileSync(agentsPath, "utf8") === sentinel, ); - check("reports the pre-existing file as skipped", result.skipped.includes("AGENTS.md")); + check( + "reports the pre-existing, user-authored file as customized", + result.customized.includes("AGENTS.md") && !result.skipped.includes("AGENTS.md"), + ); check("still creates the other files", existsSync(join(dir, "docs", "repo-memory.md"))); // Re-running is a no-op: everything already present is skipped. const second = install({ target: dir }); check("second run creates nothing new", second.created.length === 0); check( - "second run refreshes only the version marker", - second.updated.length === 1 && second.updated[0] === join(".cursor", ".cursor-os-version"), + "second run refreshes only generated files", + second.updated.length === 2 && + second.updated.includes(join(".cursor", ".cursor-os-version")) && + second.updated.includes(join(".cursor", ".cursor-os-manifest.json")), ); check( - "second run skips every template file", - second.skipped.length === EXPECTED.length, + "second run leaves every template file untouched", + second.skipped.length + second.stale.length + second.customized.length === EXPECTED.length, ); }); @@ -546,6 +559,135 @@ withTempDir((dir) => { check("CLI detect --dry-run prints error", dryRunDetect.stderr.includes("--dry-run is only valid with init")); }); + +// ── Regression coverage added by the audit ──────────────────────────────────── + +// Optional (prunable) rules: localization is told to delete frontend.mdc when +// the project has no UI. doctor must not call that a broken install. +console.log("\npruned optional rules:"); +withTempDir((dir) => { + install({ target: dir }); + rmSync(join(dir, ".cursor", "rules", "frontend.mdc")); + const health = doctor({ target: dir }); + check("pruning frontend.mdc leaves zero required files missing", health.missingRequired === 0); + const pruned = health.checks.find((c) => c.label === join(".cursor", "rules", "frontend.mdc")); + check("pruned optional rule is flagged optional, not missing", pruned.optional === true && pruned.present === false); + + const cli = runCli(["doctor", "--target", dir]); + check("CLI doctor exits 0 after an optional rule is pruned", cli.status === 0); + check("CLI doctor labels the pruned rule", cli.stdout.includes("pruned")); + check("CLI doctor does not claim a broken install", !cli.stdout.includes("not fully installed")); + + // A required file going missing must still fail. + rmSync(join(dir, "AGENTS.md")); + const broken = runCli(["doctor", "--target", dir]); + check("CLI doctor still fails when a required file is missing", broken.status === 1); +}); + +// Target validation: a typo must not scatter the kit into a fabricated tree. +console.log("\ntarget validation:"); +withTempDir((dir) => { + const deep = join(dir, "no", "such", "tree"); + const cli = runCli(["init", "--target", deep]); + check("init refuses a target whose parent does not exist", cli.status === 1); + check("init explains the missing parent", cli.stderr.includes("parent")); + check("init wrote nothing for the bad target", !existsSync(join(dir, "no"))); + + const oneLevel = join(dir, "new-project"); + const ok = runCli(["init", "--target", oneLevel]); + check("init still creates a single new directory level", ok.status === 0); + check("init populated the new directory", existsSync(join(oneLevel, "AGENTS.md"))); + + const filePath = join(dir, "a-file.txt"); + writeFileSync(filePath, "not a directory\n", "utf8"); + const notDir = runCli(["init", "--target", filePath]); + check("init rejects a file as target", notDir.status === 1); + check("init names the not-a-directory problem", notDir.stderr.includes("not a directory")); + + const missingDoctor = runCli(["doctor", "--target", join(dir, "absent")]); + check("doctor on a missing dir exits non-zero", missingDoctor.status === 1); + check( + "doctor on a missing dir says the dir is missing, not that the OS is uninstalled", + missingDoctor.stderr.includes("does not exist") && !missingDoctor.stdout.includes("not fully installed"), + ); +}); + +// --update: refresh stale kit files, never clobber edited ones. +console.log("\nupdate semantics:"); +withTempDir((dir) => { + install({ target: dir }); + const corePath = join(dir, ".cursor", "rules", "core.mdc"); + const agentsPath = join(dir, "AGENTS.md"); + const pristineCore = readFileSync(corePath, "utf8"); + + // Simulate a stale file from an older release by rewriting the manifest hash + // to match the on-disk content after we mutate it... instead, mutate the file + // and re-record it, which is exactly the "installed, never edited" state. + writeFileSync(corePath, "stale content from an older release\n", "utf8"); + const manifestPath = join(dir, ".cursor", ".cursor-os-manifest.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.files[".cursor/rules/core.mdc"] = createHash("sha256") + .update(readFileSync(corePath)) + .digest("hex"); + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8"); + + // A genuine user edit, which must survive --update. + const userEdit = readFileSync(agentsPath, "utf8") + "\n## Our team rule\nAlways run make check.\n"; + writeFileSync(agentsPath, userEdit, "utf8"); + + const preview = install({ target: dir, update: true, dryRun: true }); + check("update dry-run reports the stale file as refreshable", preview.refreshed.includes(join(".cursor", "rules", "core.mdc"))); + check("plain init classifies it as stale, not skipped", (() => { + const plain = install({ target: dir, dryRun: true }); + return plain.stale.includes(join(".cursor", "rules", "core.mdc")) + && !plain.skipped.includes(join(".cursor", "rules", "core.mdc")); + })()); + check("update dry-run writes nothing", readFileSync(corePath, "utf8") === "stale content from an older release\n"); + + const applied = install({ target: dir, update: true }); + check("update refreshes the unedited stale file", readFileSync(corePath, "utf8") === pristineCore); + check("update preserves the user-edited file byte-for-byte", readFileSync(agentsPath, "utf8") === userEdit); + check("update reports the edited file as customized", applied.customized.includes("AGENTS.md")); + + const cli = runCli(["init", "--update", "--target", dir]); + check("CLI init --update exits 0", cli.status === 0); + + const badFlag = runCli(["doctor", "--update", "--target", dir]); + check("CLI rejects --update outside init", badFlag.status === 1); + check("CLI explains the --update restriction", badFlag.stderr.includes("--update is only valid with init")); +}); + +// Plain init must never overwrite, even when a file is stale. +console.log("\ninit without --update never overwrites:"); +withTempDir((dir) => { + install({ target: dir }); + const corePath = join(dir, ".cursor", "rules", "core.mdc"); + writeFileSync(corePath, "user rewrote this\n", "utf8"); + const result = install({ target: dir }); + check("plain init leaves the edited file alone", readFileSync(corePath, "utf8") === "user rewrote this\n"); + check("plain init reports it as customized", result.customized.includes(join(".cursor", "rules", "core.mdc"))); + check("an edited file is never reported as stale", !result.stale.includes(join(".cursor", "rules", "core.mdc"))); +}); + +// Regression: a stray .DS_Store in template/ used to be copied into every install +// and broke three count-based checks, on macOS only. +console.log("\nOS artifacts in template/:"); +withTempDir((dir) => { + const junk = join(repoRoot, "template", ".DS_Store"); + const preexisting = existsSync(junk); + if (!preexisting) writeFileSync(junk, "", "utf8"); + try { + const result = install({ target: dir }); + check("install ignores .DS_Store in template/", !result.created.some((f) => f.includes(".DS_Store"))); + check("no OS artifact reaches the target", !existsSync(join(dir, ".DS_Store"))); + check("expected file count is unchanged", result.created.length === EXPECTED.length + 2); + check("doctor does not check for OS artifacts", + !doctor({ target: dir }).checks.some((c) => c.label.includes(".DS_Store"))); + } finally { + if (!preexisting) rmSync(junk, { force: true }); + } +}); + console.log(`\n${passed} checks passed, ${failures.length} failed.`); if (failures.length) { console.error("\nFailures:"); diff --git a/template/.cursor/rules/frontend.mdc b/template/.cursor/rules/frontend.mdc index 3faa607..a656f5d 100644 --- a/template/.cursor/rules/frontend.mdc +++ b/template/.cursor/rules/frontend.mdc @@ -1,10 +1,6 @@ --- description: UI/component conventions. Apply when building or changing user-facing frontend code. -globs: - - "**/*.tsx" - - "**/*.jsx" - - "**/*.vue" - - "**/*.svelte" +globs: **/*.tsx,**/*.jsx,**/*.vue,**/*.svelte alwaysApply: false --- From 3e75b91ade12cd8a2d3e656f0da5540505b1a9d3 Mon Sep 17 00:00:00 2001 From: KingEmma Date: Fri, 7 Aug 2026 21:52:23 +0000 Subject: [PATCH 2/6] fix(installer): harden safe update ownership semantics --- scripts/init.mjs | 608 +++++++++++++++++++++++++---------------------- 1 file changed, 330 insertions(+), 278 deletions(-) diff --git a/scripts/init.mjs b/scripts/init.mjs index 5717f86..ba3f9ce 100755 --- a/scripts/init.mjs +++ b/scripts/init.mjs @@ -1,7 +1,6 @@ #!/usr/bin/env node // Cursor OS installer. -// Copies the template/ kit into a target project. Idempotent: never overwrites -// user files (skips them), supports --dry-run, and refreshes a version marker. +// Copies the template kit without silently taking ownership of pre-existing files. // Node built-ins only — no dependencies. import { @@ -14,6 +13,8 @@ import { lstatSync, realpathSync, statSync, + renameSync, + rmSync, } from "node:fs"; import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; @@ -27,24 +28,15 @@ const repoRoot = join(__dirname, ".."); const templateDir = join(repoRoot, "template"); const MARKER_REL = join(".cursor", ".cursor-os-version"); const MANIFEST_REL = join(".cursor", ".cursor-os-manifest.json"); - -// Prose files doctor scans for unfilled placeholder markers. +const MANIFEST_SCHEMA = 1; +const SHA256_RE = /^[a-f0-9]{64}$/; const TODO_FILES = ["AGENTS.md", join("docs", "repo-memory.md")]; - -// OS and editor artifacts that must never be treated as part of the kit. Without -// this, a Finder visit to template/ adds .DS_Store to every install and breaks the -// count-based smoke checks on macOS only, where CI cannot see it. const IGNORED_NAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".AppleDouble"]); - -// Opt-in rules that localization is instructed to delete when they don't apply -// (see prompts/localize-cursor-os.md, step 7). doctor reports these as pruned -// rather than missing, so a correctly localized project still passes. const OPTIONAL_FILES = new Set([ join(".cursor", "rules", "frontend.mdc"), join(".cursor", "rules", "debugging.mdc"), ]); -/** Stable manifest key, independent of the platform path separator. */ function manifestKey(rel) { return rel.split(sep).join("/"); } @@ -53,37 +45,83 @@ function hashFile(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); } -/** Read the install manifest, or null when absent/unreadable (pre-0.3 installs). */ -function readManifest(target) { +function pathKind(path) { try { - const parsed = JSON.parse(readFileSync(join(target, MANIFEST_REL), "utf8")); - return parsed && parsed.files && typeof parsed.files === "object" ? parsed : null; - } catch { - return null; + const stat = lstatSync(path); + if (stat.isSymbolicLink()) return "symlink"; + if (stat.isFile()) return "file"; + if (stat.isDirectory()) return "directory"; + return "other"; + } catch (error) { + if (error?.code === "ENOENT") return "missing"; + throw error; + } +} + +function readManifestState(target) { + const path = join(target, MANIFEST_REL); + const kind = pathKind(path); + if (kind === "missing") return { status: "missing", manifest: null, error: null }; + if (kind !== "file") { + return { status: "invalid", manifest: null, error: `${MANIFEST_REL} is not a regular file` }; + } + + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + if (!parsed || parsed.schemaVersion !== MANIFEST_SCHEMA) { + return { + status: "invalid", + manifest: null, + error: `${MANIFEST_REL} uses unsupported schema ${parsed?.schemaVersion ?? "unknown"}; expected ${MANIFEST_SCHEMA}`, + }; + } + if (!parsed.files || typeof parsed.files !== "object" || Array.isArray(parsed.files)) { + return { status: "invalid", manifest: null, error: `${MANIFEST_REL} has an invalid files map` }; + } + for (const [key, hash] of Object.entries(parsed.files)) { + if (typeof hash !== "string" || !SHA256_RE.test(hash)) { + return { status: "invalid", manifest: null, error: `${MANIFEST_REL} has an invalid hash for ${key}` }; + } + } + const pruned = Array.isArray(parsed.pruned) ? parsed.pruned : []; + if (!pruned.every((key) => typeof key === "string")) { + return { status: "invalid", manifest: null, error: `${MANIFEST_REL} has an invalid pruned list` }; + } + return { + status: "valid", + manifest: { + schemaVersion: MANIFEST_SCHEMA, + version: typeof parsed.version === "string" ? parsed.version : null, + files: { ...parsed.files }, + pruned: [...new Set(pruned)].sort(), + }, + error: null, + }; + } catch (error) { + return { status: "invalid", manifest: null, error: `${MANIFEST_REL} is unreadable: ${error.message}` }; + } +} + +function writeManifestAtomic(target, manifest) { + const dest = join(target, MANIFEST_REL); + const temp = `${dest}.tmp-${process.pid}`; + mkdirSync(dirname(dest), { recursive: true }); + try { + writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + renameSync(temp, dest); + } finally { + rmSync(temp, { force: true }); } } -/** - * Validate a target path. An existing target must be a directory. - * - * A missing target is acceptable only for init, and only one level below an - * existing parent: `cursor-os init my-project` keeps working, while a typo such - * as `--target ../projcts/app/web` is refused instead of silently creating the - * whole tree. doctor and detect always reject a missing target so the user sees - * "no such directory" rather than "not installed". - */ function assertUsableTarget(target, { allowCreate = false } = {}) { const resolved = resolve(target); if (existsSync(resolved)) { - if (!statSync(resolved).isDirectory()) { - throw new Error(`target is not a directory: ${resolved}`); - } + if (!statSync(resolved).isDirectory()) throw new Error(`target is not a directory: ${resolved}`); return resolved; } if (!allowCreate) { - throw new Error( - `target directory does not exist: ${resolved}\n Check the path, or run init there first.`, - ); + throw new Error(`target directory does not exist: ${resolved}\n Check the path, or run init there first.`); } const parent = dirname(resolved); if (!existsSync(parent) || !statSync(parent).isDirectory()) { @@ -96,29 +134,12 @@ function assertUsableTarget(target, { allowCreate = false } = {}) { function readVersion() { try { - const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")); - return pkg.version ?? "0.0.0"; + return JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version ?? "0.0.0"; } catch { return "0.0.0"; } } -// ── Argument parsing ────────────────────────────────────────────────────────── - -/** - * Parse argv into { command, dryRun, target, format, help, version }. - * command: "init" | "doctor" | "detect" | null - * - * Supported forms: - * node init.mjs init [target] [--dry-run] [--target DIR] - * node init.mjs doctor [target] [--target DIR] - * node init.mjs detect [target] [--target DIR] [--format text|json] - * node init.mjs --help | -h - * node init.mjs --version | -v - * - * A command is required when any other argument is given. A bare invocation - * with no arguments prints help — it never writes files. - */ function parseArgs(argv) { const args = { command: null, @@ -136,15 +157,14 @@ function parseArgs(argv) { for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === "--help" || a === "-h") { args.help = true; } - else if (a === "--version" || a === "-v") { args.version = true; } - else if (a === "--dry-run" || a === "-n") { args.dryRun = true; } - else if (a === "--update" || a === "-u") { args.update = true; } + if (a === "--help" || a === "-h") args.help = true; + else if (a === "--version" || a === "-v") args.version = true; + else if (a === "--dry-run" || a === "-n") args.dryRun = true; + else if (a === "--update" || a === "-u") args.update = true; else if (a === "--format") { const value = argv[i + 1]; - if (!value || value.startsWith("-")) { - args.errors.push("--format requires text or json"); - } else if (value !== "text" && value !== "json") { + if (!value || value.startsWith("-")) args.errors.push("--format requires text or json"); + else if (value !== "text" && value !== "json") { args.errors.push(`unsupported format: ${value}`); i++; } else { @@ -152,56 +172,32 @@ function parseArgs(argv) { formatSet = true; i++; } - } - else if (a === "--target" || a === "-t") { + } else if (a === "--target" || a === "-t") { const value = argv[i + 1]; - if (!value || value.startsWith("-")) { - args.errors.push(`${a} requires a directory value`); - } else { + if (!value || value.startsWith("-")) args.errors.push(`${a} requires a directory value`); + else { args.target = value; targetSet = true; i++; } - } - else if ((a === "init" || a === "doctor" || a === "detect") && args.command === null) { - // Subcommand recognized regardless of whether --target has already been set + } else if ((a === "init" || a === "doctor" || a === "detect") && args.command === null) { args.command = a; - } - else if (a.startsWith("-")) { - args.errors.push(`unknown option: ${a}`); - } + } else if (a.startsWith("-")) args.errors.push(`unknown option: ${a}`); else if (!targetSet) { - // Any bare, non-flag, non-subcommand word is a target path - // (absolute, relative, or a plain directory name). args.target = a; targetSet = true; - } else { - args.errors.push(`unexpected argument: ${a}`); - } + } else args.errors.push(`unexpected argument: ${a}`); } - // A command is required whenever arguments are given. Bare invocation - // (no args at all) falls through to help so `npx cursor-os` is read-only. if (args.command === null && !args.help && !args.version && !args.bare) { args.errors.push("missing command: specify 'init', 'doctor' or 'detect'"); } - - if (args.command !== "init" && args.dryRun) { - args.errors.push("--dry-run is only valid with init"); - } - - if (args.command !== "init" && args.update) { - args.errors.push("--update is only valid with init"); - } - - if (args.command !== "detect" && formatSet) { - args.errors.push("--format is only valid with detect"); - } - + if (args.command !== "init" && args.dryRun) args.errors.push("--dry-run is only valid with init"); + if (args.command !== "init" && args.update) args.errors.push("--update is only valid with init"); + if (args.command !== "detect" && formatSet) args.errors.push("--format is only valid with detect"); return args; } - const HELP = `Cursor OS — installer Usage: @@ -209,7 +205,7 @@ Usage: Commands: init Install Cursor OS into the target directory - doctor Check whether Cursor OS is installed in the target directory + doctor Check install, localization, ownership and update health detect Read project manifests and report stack signals (never writes) Arguments: @@ -217,7 +213,7 @@ Arguments: Options: -n, --dry-run Preview changes without writing anything (init only) - -u, --update Refresh kit files you never edited to the current version (init only) + -u, --update Refresh only managed files Cursor OS can prove are unedited -t, --target DIR Use DIR as the target directory --format TYPE Output text or json (detect only; default: text) -v, --version Print version and exit @@ -235,25 +231,18 @@ Examples: Notes: A command is required; bare invocation prints this help and writes nothing. - The target directory must already exist. + doctor and detect require an existing target. init may create one final directory + level when its parent exists; it will not fabricate a missing directory tree. For a target directory named "init", "doctor" or "detect", or one starting with "-", use the intended command with the explicit form: --target . When running from a local checkout: node scripts/init.mjs The installer copies AGENTS.md, .cursor/, docs/, and prompts/ into the target. -It never overwrites a file you have edited. With --update it refreshes only the -files that still match what a previous install wrote; anything else is reported -as customized so you can merge it yourself. +Pre-existing files remain unmanaged and are never silently adopted. --update only +refreshes files recorded as managed and still byte-identical to what Cursor OS last +wrote. Optional rules deleted during localization remain pruned. After installing, open Cursor and run prompts/localize-cursor-os.md.`; -// ── File helpers ────────────────────────────────────────────────────────────── - -/** - * Recursively collect files under dir as paths relative to dir. - * Uses lstat so symlinked directories are not recursed into (prevents cycles). - * Symlinks to files are included and copied as their target's content by - * copyFileSync; symlinks to directories are skipped (not recursed, not copied). - */ function listFiles(dir) { const out = []; for (const entry of readdirSync(dir)) { @@ -261,88 +250,133 @@ function listFiles(dir) { const full = join(dir, entry); if (lstatSync(full).isDirectory()) { for (const child of listFiles(full)) out.push(join(entry, child)); - } else { - out.push(entry); - } + } else out.push(entry); } return out; } -// ── install ─────────────────────────────────────────────────────────────────── - /** - * Plan + apply the install. - * Returns { created, updated, skipped, target, dryRun, version }. - * Never overwrites user files: any template path that already exists is skipped. + * Plan and apply installation changes. + * manifest.files is an ownership ledger: only paths Cursor OS actually wrote are + * recorded. Existing files are deliberately never adopted. */ export function install({ target, dryRun = false, update = false } = {}) { if (!target) throw new Error("install() requires a target directory"); - if (!existsSync(templateDir)) { - throw new Error(`template directory not found at ${templateDir}`); - } + if (!existsSync(templateDir)) throw new Error(`template directory not found at ${templateDir}`); assertUsableTarget(target, { allowCreate: true }); const version = readVersion(); - const result = { - created: [], - refreshed: [], - skipped: [], - stale: [], - customized: [], - updated: [], - target, - dryRun, - update, - version, - }; + const manifestState = readManifestState(target); + if (manifestState.status === "invalid") { + throw new Error( + `${manifestState.error}. Refusing to change ownership state. Inspect or remove the manifest, then run plain init; existing files will remain unmanaged.`, + ); + } - const priorManifest = readManifest(target); + const priorFiles = manifestState.manifest?.files ?? {}; + const priorPruned = new Set(manifestState.manifest?.pruned ?? []); const nextFiles = {}; + const nextPruned = new Set(); + const templateFiles = listFiles(templateDir).sort(); + const templateKeys = new Set(templateFiles.map(manifestKey)); + const result = { + created: [], restored: [], refreshed: [], skipped: [], stale: [], customized: [], + unmanaged: [], pruned: [], obsolete: [], removed: [], conflicts: [], updated: [], + target, dryRun, update, version, manifestStatus: manifestState.status, + }; - for (const rel of listFiles(templateDir).sort()) { + for (const rel of templateFiles) { const src = join(templateDir, rel); const dest = join(target, rel); - const templateHash = hashFile(src); const key = manifestKey(rel); - - if (!existsSync(dest)) { + const templateHash = hashFile(src); + const recordedHash = priorFiles[key] ?? null; + const wasPruned = priorPruned.has(key); + const kind = pathKind(dest); + + if (kind === "missing") { + if (wasPruned || (recordedHash !== null && OPTIONAL_FILES.has(rel))) { + result.pruned.push(rel); + nextPruned.add(key); + continue; + } if (!dryRun) { mkdirSync(dirname(dest), { recursive: true }); copyFileSync(src, dest); } - result.created.push(rel); + (recordedHash !== null ? result.restored : result.created).push(rel); nextFiles[key] = templateHash; continue; } + if (kind !== "file") { + result.conflicts.push({ rel, kind }); + if (recordedHash !== null) nextFiles[key] = recordedHash; + if (wasPruned) nextPruned.add(key); + continue; + } + const currentHash = hashFile(dest); - if (currentHash === templateHash) { - // Byte-identical to the shipped template; nothing to do. - result.skipped.push(rel); - nextFiles[key] = templateHash; + if (recordedHash !== null) { + if (currentHash === recordedHash) { + if (currentHash === templateHash) { + result.skipped.push(rel); + nextFiles[key] = templateHash; + } else if (update) { + if (!dryRun) copyFileSync(src, dest); + result.refreshed.push(rel); + nextFiles[key] = templateHash; + } else { + result.stale.push(rel); + nextFiles[key] = recordedHash; + } + } else { + result.customized.push(rel); + nextFiles[key] = recordedHash; + } continue; } - // The file differs from the template. The manifest tells us whether that is - // an edit worth preserving or drift from an older release worth refreshing. - const recordedHash = priorManifest?.files?.[key] ?? null; - const untouchedSinceInstall = recordedHash !== null && recordedHash === currentHash; + result.customized.push(rel); + result.unmanaged.push(rel); + } - if (update && untouchedSinceInstall) { - if (!dryRun) copyFileSync(src, dest); - result.refreshed.push(rel); - nextFiles[key] = templateHash; + for (const key of priorPruned) { + if (!templateKeys.has(key)) continue; + const rel = key.split("/").join(sep); + if (pathKind(join(target, rel)) === "missing") nextPruned.add(key); + } + + for (const [key, recordedHash] of Object.entries(priorFiles)) { + if (templateKeys.has(key)) continue; + const rel = key.split("/").join(sep); + const dest = join(target, rel); + const kind = pathKind(dest); + if (kind === "missing") continue; + if (kind !== "file") { + result.conflicts.push({ rel, kind }); + nextFiles[key] = recordedHash; + continue; + } + if (hashFile(dest) !== recordedHash) { + result.customized.push(rel); + nextFiles[key] = recordedHash; + continue; + } + if (update) { + if (!dryRun) rmSync(dest); + result.removed.push(rel); } else { - // Never overwrite an edit. Without a manifest every difference is treated - // as an edit, which is the safe reading for installs predating 0.3.0. - if (untouchedSinceInstall) result.stale.push(rel); - else result.customized.push(rel); - nextFiles[key] = recordedHash ?? currentHash; + result.obsolete.push(rel); + nextFiles[key] = recordedHash; } } const markerDest = join(target, MARKER_REL); - if (existsSync(markerDest)) { + const markerKind = pathKind(markerDest); + if (markerKind !== "missing" && markerKind !== "file") { + result.conflicts.push({ rel: MARKER_REL, kind: markerKind }); + } else if (markerKind === "file") { if (!dryRun) writeFileSync(markerDest, `cursor-os ${version}\n`, "utf8"); result.updated.push(MARKER_REL); } else { @@ -353,168 +387,200 @@ export function install({ target, dryRun = false, update = false } = {}) { result.created.push(MARKER_REL); } - const manifestDest = join(target, MANIFEST_REL); - const manifestExisted = existsSync(manifestDest); + const manifestExisted = manifestState.status === "valid"; if (!dryRun) { - mkdirSync(dirname(manifestDest), { recursive: true }); - writeFileSync( - manifestDest, - `${JSON.stringify({ schemaVersion: 1, version, files: nextFiles }, null, 2)}\n`, - "utf8", - ); + writeManifestAtomic(target, { + schemaVersion: MANIFEST_SCHEMA, + version, + files: nextFiles, + pruned: [...nextPruned].sort(), + }); } (manifestExisted ? result.updated : result.created).push(MANIFEST_REL); - return result; } -// ── doctor ──────────────────────────────────────────────────────────────────── - function doctorChecks() { - if (!existsSync(templateDir)) { - throw new Error(`template directory not found at ${templateDir}`); - } - + if (!existsSync(templateDir)) throw new Error(`template directory not found at ${templateDir}`); return [ ...listFiles(templateDir).sort().map((rel) => ({ rel, label: rel })), - { rel: MARKER_REL, label: MARKER_REL }, + { rel: MARKER_REL, label: MARKER_REL, generated: true }, ]; } -/** Read the installed version from the marker file, or null if unreadable. */ function readMarkerVersion(target) { try { - const content = readFileSync(join(target, MARKER_REL), "utf8"); - return content.match(/cursor-os (\S+)/)?.[1] ?? null; + return readFileSync(join(target, MARKER_REL), "utf8").match(/cursor-os (\S+)/)?.[1] ?? null; } catch { return null; } } -/** - * Check whether Cursor OS appears installed in target. - * Returns { checks: [{label, present, note}], todoCount, markerVersion, target }. - * Never modifies files. - */ export function doctor({ target } = {}) { if (!target) throw new Error("doctor() requires a target directory"); assertUsableTarget(target); + const manifestState = readManifestState(target); + const manifest = manifestState.manifest; + const owned = manifest?.files ?? {}; + const prunedKeys = new Set(manifest?.pruned ?? []); - const checks = doctorChecks().map(({ rel, label }) => { + const checks = doctorChecks().map(({ rel, label, generated = false }) => { const fullPath = join(target, rel); - const present = existsSync(fullPath); - const optional = OPTIONAL_FILES.has(rel); + const kind = pathKind(fullPath); + const present = kind === "file"; + const optional = !generated && OPTIONAL_FILES.has(rel); + const key = manifestKey(rel); + const recordedHash = generated ? null : owned[key] ?? null; + const managed = recordedHash !== null; + const pruned = !present && optional && (managed || prunedKeys.has(key)); + const conflict = kind !== "file" && kind !== "missing"; let note = null; let todoCount = 0; + let stale = false; + let customized = false; + let unmanaged = false; - // Flag unfilled TODO placeholders in key prose files if (present && TODO_FILES.includes(rel)) { - try { - const content = readFileSync(fullPath, "utf8"); - todoCount = (content.match(/\bTODO\b/g) ?? []).length; - if (todoCount > 0) note = `${todoCount} TODO placeholder(s) remain — run prompts/localize-cursor-os.md`; - } catch { - // ignore read errors - } + const content = readFileSync(fullPath, "utf8"); + todoCount = (content.match(/\bTODO\b/g) ?? []).length; + if (todoCount > 0) note = `${todoCount} TODO placeholder(s) remain — run prompts/localize-cursor-os.md`; } - if (!present && optional) { - note = "optional rule — absent because localization pruned it, or never installed"; + if (conflict) note = `${kind} at an installer path; Cursor OS will not follow or replace it`; + else if (!present && optional) { + note = pruned + ? "optional rule intentionally absent; future init/update will preserve pruning" + : "optional rule absent"; + } else if (present && managed) { + const currentHash = hashFile(fullPath); + const templateHash = hashFile(join(templateDir, rel)); + customized = currentHash !== recordedHash; + stale = !customized && currentHash !== templateHash; + if (stale) note = "managed file is behind the current template; run init --update"; + else if (customized) note = "managed file has local edits; update will preserve it"; + } else if (present && !managed && !generated) { + unmanaged = true; + if (!note) note = "pre-existing/unmanaged file; Cursor OS will never overwrite it"; } - return { label, present, optional, note, todoCount }; + return { label, present, optional, managed, pruned, conflict, kind, stale, customized, unmanaged, note, todoCount }; }); + const currentKeys = new Set(listFiles(templateDir).map(manifestKey)); + const obsolete = []; + for (const [key, recordedHash] of Object.entries(owned)) { + if (currentKeys.has(key)) continue; + const rel = key.split("/").join(sep); + const dest = join(target, rel); + const kind = pathKind(dest); + if (kind === "missing") continue; + obsolete.push({ rel, kind, customized: kind === "file" && hashFile(dest) !== recordedHash }); + } + const todoCount = checks.reduce((n, c) => n + c.todoCount, 0); - const missingRequired = checks.filter((c) => !c.present && !c.optional).length; + const missingRequired = checks.filter((c) => !c.present && !c.optional && !c.conflict).length; + const conflicts = checks.filter((c) => c.conflict).length; + const staleManaged = checks.filter((c) => c.stale).length; + const customizedManaged = checks.filter((c) => c.customized).length; + const unmanaged = checks.filter((c) => c.unmanaged).length; return { - checks, - todoCount, - missingRequired, + checks, todoCount, missingRequired, conflicts, staleManaged, customizedManaged, unmanaged, obsolete, + manifestStatus: manifestState.status, + manifestError: manifestState.error, + updateSafe: manifestState.status === "valid", + manifestVersion: manifest?.version ?? null, markerVersion: readMarkerVersion(target), target, }; } -// ── CLI entry point ─────────────────────────────────────────────────────────── - function runInit(args) { const result = install(args); console.log(`Cursor OS v${result.version}${args.dryRun ? " (dry run)" : ""}`); console.log(`Target: ${args.target}\n`); - const verb = args.dryRun ? "Would create" : "Created"; if (result.created.length) { console.log(`${verb} ${result.created.length} file(s):`); for (const f of result.created) console.log(` + ${f}`); } + if (result.restored.length) { + console.log(`\n${args.dryRun ? "Would restore" : "Restored"} ${result.restored.length} missing managed file(s):`); + for (const f of result.restored) console.log(` + ${f}`); + } if (result.refreshed.length) { - const refreshVerb = args.dryRun ? "Would refresh" : "Refreshed"; - console.log(`\n${refreshVerb} ${result.refreshed.length} unedited file(s):`); + console.log(`\n${args.dryRun ? "Would refresh" : "Refreshed"} ${result.refreshed.length} unedited managed file(s):`); for (const f of result.refreshed) console.log(` ^ ${f}`); } if (result.skipped.length) { - console.log(`\nSkipped ${result.skipped.length} up-to-date file(s):`); + console.log(`\nSkipped ${result.skipped.length} up-to-date managed file(s):`); for (const f of result.skipped) console.log(` = ${f}`); } if (result.stale.length) { - console.log(`\n${result.stale.length} unedited file(s) are behind the current template:`); + console.log(`\n${result.stale.length} managed file(s) are behind the current template:`); for (const f of result.stale) console.log(` ! ${f}`); console.log(" Run init --update to refresh them."); } if (result.customized.length) { - console.log(`\nKept ${result.customized.length} edited file(s):`); + console.log(`\nKept ${result.customized.length} customized/unmanaged file(s):`); for (const f of result.customized) console.log(` * ${f}`); - console.log(" These differ from the current template. Merge by hand if you want the new version."); + } + if (result.unmanaged.length) { + console.log(` ${result.unmanaged.length} of these are pre-existing and not owned by Cursor OS.`); + } + if (result.pruned.length) { + console.log(`\nPreserved ${result.pruned.length} pruned optional rule(s):`); + for (const f of result.pruned) console.log(` - ${f}`); + } + if (result.obsolete.length) { + console.log(`\n${result.obsolete.length} unchanged managed file(s) are no longer in the template:`); + for (const f of result.obsolete) console.log(` o ${f}`); + console.log(" Run init --update to remove them safely."); + } + if (result.removed.length) { + console.log(`\n${args.dryRun ? "Would remove" : "Removed"} ${result.removed.length} obsolete managed file(s):`); + for (const f of result.removed) console.log(` x ${f}`); + } + if (result.conflicts.length) { + console.log(`\nSkipped ${result.conflicts.length} filesystem conflict(s):`); + for (const { rel, kind } of result.conflicts) console.log(` ? ${rel} (${kind})`); + console.log(" Symlinks and non-regular files are never followed or replaced."); } if (result.updated.length) { - const updateVerb = args.dryRun ? "Would refresh" : "Refreshed"; - console.log(`\n${updateVerb} ${result.updated.length} generated file(s):`); + console.log(`\n${args.dryRun ? "Would refresh" : "Refreshed"} ${result.updated.length} generated file(s):`); for (const f of result.updated) console.log(` ~ ${f}`); } - if (args.dryRun) { console.log("\nDry run complete — no files were written."); return; } - // Post-install health check: confirm the install and surface what - // localization still needs to fill in, so the next step is unmissable. const health = doctor({ target: args.target }); - const missing = health.missingRequired; - // Show a relative path only when the target is inside this checkout. const rel = relative(repoRoot, args.target); let where = rel || "this repo"; if (rel.startsWith("..")) where = resolve(args.target); - console.log("\nPost-install check:"); - if (missing > 0) { - console.log(` ${missing} expected file(s) missing — run: cursor-os doctor --target ${args.target}`); + if (health.missingRequired > 0 || health.conflicts > 0) { + console.log(` ${health.missingRequired} required file(s) missing; ${health.conflicts} conflict(s). Run cursor-os doctor.`); + } else if (health.staleManaged > 0) { + console.log(` Installed with ${health.staleManaged} managed file(s) behind the current template.`); } else if (health.todoCount > 0) { - console.log(` All files installed. ${health.todoCount} placeholder(s) await localization.`); - } else { - console.log(" All files installed and localized."); - } + console.log(` All required files installed. ${health.todoCount} placeholder(s) await localization.`); + } else console.log(" All required files installed and localized."); if (health.todoCount > 0) { const project = detect({ target: args.target }); const signals = [...project.frameworks, ...project.services, ...project.tooling].slice(0, 8); - if (signals.length > 0) { - console.log(`\nDetected project signals: ${signals.join(", ")}`); - } + if (signals.length > 0) console.log(`\nDetected project signals: ${signals.join(", ")}`); console.log(`\nNext: open Cursor in ${where} and run prompts/localize-cursor-os.md to adapt the OS to your project.`); - console.log('Tip: with the Cursor CLI installed you can run it directly:'); - console.log(' cursor-agent -p "$(cat prompts/localize-cursor-os.md)"'); + console.log("Tip: with the Cursor CLI installed you can run it directly:"); + console.log(' agent -p "$(cat prompts/localize-cursor-os.md)"'); } } function runDetect(args) { const result = detect({ target: args.target }); - if (args.format === "json") { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(formatDetectionText(result)); - } + if (args.format === "json") console.log(JSON.stringify(result, null, 2)); + else console.log(formatDetectionText(result)); } function runDoctor(args) { @@ -522,91 +588,77 @@ function runDoctor(args) { const version = readVersion(); console.log(`Cursor OS v${version} — doctor`); console.log(`Target: ${args.target}\n`); - - for (const { label, present, optional, note } of result.checks) { - const symbol = present ? "ok " : optional ? "pruned " : "MISSING"; - console.log(` ${symbol} ${label}`); - if (note) console.log(` note: ${note}`); + for (const check of result.checks) { + let symbol = "ok "; + if (check.conflict) symbol = "CONFLICT "; + else if (!check.present && check.optional) symbol = check.pruned ? "pruned " : "optional "; + else if (!check.present) symbol = "MISSING "; + else if (check.stale) symbol = "stale "; + else if (check.customized) symbol = "custom "; + else if (check.unmanaged) symbol = "unmanaged"; + console.log(` ${symbol} ${check.label}`); + if (check.note) console.log(` note: ${check.note}`); } + console.log(""); + if (result.manifestStatus === "invalid") { + console.log(` MANIFEST INVALID: ${result.manifestError}`); + console.log(" init/update will refuse to change ownership state until this is resolved."); + } else if (result.manifestStatus === "missing") { + console.log(" note: no ownership manifest. Existing files are unmanaged; safe auto-update is unavailable for them."); + } + if (result.staleManaged > 0) console.log(` note: ${result.staleManaged} managed file(s) can be refreshed with init --update.`); + if (result.obsolete.length > 0) console.log(` note: ${result.obsolete.length} managed path(s) are no longer in the template.`); if (result.markerVersion && result.markerVersion !== version) { - console.log(`\n note: installed from cursor-os ${result.markerVersion}; current is ${version}.`); - console.log(" Re-run init to add files introduced since, or init --update to also"); - console.log(" refresh kit files you have not edited."); + console.log(` note: version marker says ${result.markerVersion}; current package is ${version}.`); } console.log(""); - if (result.missingRequired === 0 && result.todoCount === 0) { - console.log("Cursor OS appears installed and localized."); - } else if (result.missingRequired === 0) { - console.log("Cursor OS is installed. Run prompts/localize-cursor-os.md to complete setup."); - } else { - console.log(`Cursor OS is not fully installed (${result.missingRequired} required file(s) missing). Run: cursor-os init`); + const broken = result.missingRequired > 0 || result.conflicts > 0 || result.manifestStatus === "invalid"; + if (broken) { + console.log(`Cursor OS needs attention (${result.missingRequired} required missing, ${result.conflicts} conflict(s)).`); process.exitCode = 1; - } + } else if (result.todoCount > 0) { + console.log("Cursor OS is installed. Run prompts/localize-cursor-os.md to complete setup."); + } else if (result.staleManaged > 0 || result.obsolete.length > 0) { + console.log("Cursor OS is installed and localized; a safe template update is available."); + } else console.log("Cursor OS appears installed and localized."); } -// Minimum supported Node major version. Keep in sync with package.json engines. const MIN_NODE_MAJOR = 20; - function main() { - // engines in package.json is advisory only — fail fast with a clear message. const nodeMajor = Number(process.versions.node.split(".")[0]); if (nodeMajor < MIN_NODE_MAJOR) { - console.error( - `Error: cursor-os requires Node.js ${MIN_NODE_MAJOR} or newer (you are running ${process.versions.node}).`, - ); + console.error(`Error: cursor-os requires Node.js ${MIN_NODE_MAJOR} or newer (you are running ${process.versions.node}).`); process.exitCode = 1; return; } - const args = parseArgs(process.argv.slice(2)); - if (args.errors.length) { for (const error of args.errors) console.error(`Error: ${error}`); console.error(`\n${HELP}`); process.exitCode = 1; return; } - - if (args.version) { - console.log(readVersion()); - return; - } - - if (args.help || args.bare) { - console.log(HELP); - return; - } - + if (args.version) return void console.log(readVersion()); + if (args.help || args.bare) return void console.log(HELP); try { - if (args.command === "doctor") { - runDoctor(args); - } else if (args.command === "detect") { - runDetect(args); - } else { - runInit(args); - } + if (args.command === "doctor") runDoctor(args); + else if (args.command === "detect") runDetect(args); + else runInit(args); } catch (err) { console.error(`Error: ${err.message}`); process.exitCode = 1; } } -// Only run main when invoked directly, not when imported by the smoke test. -// realpathSync normalizes symlinks (e.g. macOS /tmp → /private/tmp). function isDirectInvocation() { if (!process.argv[1]) return false; try { - return ( - realpathSync(fileURLToPath(import.meta.url)) === - realpathSync(process.argv[1]) - ); + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); } catch { return fileURLToPath(import.meta.url) === process.argv[1]; } } -if (isDirectInvocation()) { - main(); -} +if (isDirectInvocation()) main(); From 35a03354060827ecd9727c08f22588402422ce80 Mon Sep 17 00:00:00 2001 From: KingEmma Date: Fri, 7 Aug 2026 21:52:54 +0000 Subject: [PATCH 3/6] test(installer): cover ownership and upgrade safety regressions --- scripts/upgrade-smoke-test.mjs | 203 +++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 scripts/upgrade-smoke-test.mjs diff --git a/scripts/upgrade-smoke-test.mjs b/scripts/upgrade-smoke-test.mjs new file mode 100644 index 0000000..ee20053 --- /dev/null +++ b/scripts/upgrade-smoke-test.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { install, doctor } from "./init.mjs"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const templateRoot = join(repoRoot, "template"); +const manifestRel = join(".cursor", ".cursor-os-manifest.json"); +const manifestPath = (target) => join(target, manifestRel); +const hash = (value) => createHash("sha256").update(value).digest("hex"); + +let passed = 0; +const failures = []; +function check(name, condition) { + if (condition) { + passed++; + console.log(` ok ${name}`); + } else { + failures.push(name); + console.log(`FAIL ${name}`); + } +} + +function withTempDir(fn) { + const dir = mkdtempSync(join(tmpdir(), "cursor-os-upgrade-")); + try { + fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function withTemplateMutation(rel, replacement, fn) { + const path = join(templateRoot, rel); + const original = readFileSync(path); + try { + writeFileSync(path, replacement); + fn(path, original); + } finally { + writeFileSync(path, original); + } +} + +console.log("Cursor OS — upgrade safety regression test\n"); + +console.log("pre-existing ownership:"); +withTempDir((dir) => { + const agents = join(dir, "AGENTS.md"); + const userContent = "# Existing project contract\nNever replace this.\n"; + writeFileSync(agents, userContent); + + const first = install({ target: dir }); + const manifest = JSON.parse(readFileSync(manifestPath(dir), "utf8")); + check("pre-existing file is reported as customized", first.customized.includes("AGENTS.md")); + check("pre-existing file is explicitly reported unmanaged", first.unmanaged.includes("AGENTS.md")); + check("pre-existing file is not silently adopted", manifest.files["AGENTS.md"] === undefined); + + withTemplateMutation("AGENTS.md", "# Future Cursor OS contract\n", () => { + install({ target: dir, update: true }); + check("later --update cannot overwrite an unowned pre-existing file", readFileSync(agents, "utf8") === userContent); + }); +}); + +console.log("\npruned optional rules:"); +withTempDir((dir) => { + install({ target: dir }); + const frontend = join(dir, ".cursor", "rules", "frontend.mdc"); + rmSync(frontend); + + const updated = install({ target: dir, update: true }); + const manifest = JSON.parse(readFileSync(manifestPath(dir), "utf8")); + check("init --update preserves an intentionally removed optional rule", !existsSync(frontend)); + check("pruned rule is reported", updated.pruned.includes(join(".cursor", "rules", "frontend.mdc"))); + check("pruned state is persisted", manifest.pruned.includes(".cursor/rules/frontend.mdc")); + check("pruned rule is removed from managed hashes", manifest.files[".cursor/rules/frontend.mdc"] === undefined); + + install({ target: dir }); + check("plain init also preserves pruning", !existsSync(frontend)); +}); + +console.log("\nrequired managed-file restoration:"); +withTempDir((dir) => { + install({ target: dir }); + const required = join(dir, "prompts", "plan-feature.md"); + rmSync(required); + const result = install({ target: dir }); + check("missing required managed file is restored", existsSync(required)); + check("restoration is reported separately", result.restored.includes(join("prompts", "plan-feature.md"))); +}); + +console.log("\nmanifest corruption:"); +withTempDir((dir) => { + install({ target: dir }); + writeFileSync(manifestPath(dir), "{ definitely-not-json\n", "utf8"); + let error = null; + try { + install({ target: dir, update: true }); + } catch (caught) { + error = caught; + } + check("invalid manifest blocks --update", Boolean(error)); + check("invalid manifest explains ownership safety", /ownership state/i.test(error?.message ?? "")); + const health = doctor({ target: dir }); + check("doctor reports invalid manifest", health.manifestStatus === "invalid"); + check("doctor disables safe update when manifest is invalid", health.updateSafe === false); +}); + +console.log("\nmissing manifest / legacy install:"); +withTempDir((dir) => { + install({ target: dir }); + rmSync(manifestPath(dir)); + const agents = join(dir, "AGENTS.md"); + const customized = readFileSync(agents, "utf8") + "\n# Local rule\n"; + writeFileSync(agents, customized); + + const result = install({ target: dir }); + const manifest = JSON.parse(readFileSync(manifestPath(dir), "utf8")); + check("legacy existing file is preserved", readFileSync(agents, "utf8") === customized); + check("legacy existing file remains unmanaged", manifest.files["AGENTS.md"] === undefined); + check("legacy existing file is reported unmanaged", result.unmanaged.includes("AGENTS.md")); +}); + +console.log("\nfilesystem conflicts:"); +if (process.platform === "win32") { + console.log(" ok symlink write-through regression is covered on Unix CI"); + passed++; +} else { + withTempDir((dir) => { + install({ target: dir }); + const rule = join(dir, ".cursor", "rules", "debugging.mdc"); + const outside = join(dir, "outside.txt"); + const outsideContent = "outside must stay untouched\n"; + writeFileSync(outside, outsideContent); + rmSync(rule); + symlinkSync(outside, rule); + + const result = install({ target: dir, update: true }); + check("destination symlink is reported as a conflict", result.conflicts.some((item) => item.rel === join(".cursor", "rules", "debugging.mdc"))); + check("installer never writes through destination symlink", readFileSync(outside, "utf8") === outsideContent); + const health = doctor({ target: dir }); + check("doctor reports symlink conflict", health.conflicts > 0); + }); +} + +console.log("\nobsolete managed files:"); +withTempDir((dir) => { + install({ target: dir }); + const obsoleteRel = join("prompts", "removed-in-future.md"); + const obsoleteKey = "prompts/removed-in-future.md"; + const obsolete = join(dir, obsoleteRel); + const content = "old cursor-os workflow\n"; + writeFileSync(obsolete, content); + + const manifest = JSON.parse(readFileSync(manifestPath(dir), "utf8")); + manifest.files[obsoleteKey] = hash(content); + writeFileSync(manifestPath(dir), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + + const plain = install({ target: dir }); + check("plain init reports unchanged obsolete managed file", plain.obsolete.includes(obsoleteRel)); + check("plain init does not remove obsolete file", existsSync(obsolete)); + + const update = install({ target: dir, update: true }); + check("--update removes unchanged obsolete managed file", !existsSync(obsolete)); + check("obsolete removal is reported", update.removed.includes(obsoleteRel)); +}); + +console.log("\ncustomized obsolete files:"); +withTempDir((dir) => { + install({ target: dir }); + const obsoleteRel = join("prompts", "removed-but-customized.md"); + const obsoleteKey = "prompts/removed-but-customized.md"; + const obsolete = join(dir, obsoleteRel); + const installed = "old cursor-os workflow\n"; + writeFileSync(obsolete, "team customized this workflow\n"); + + const manifest = JSON.parse(readFileSync(manifestPath(dir), "utf8")); + manifest.files[obsoleteKey] = hash(installed); + writeFileSync(manifestPath(dir), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + + const result = install({ target: dir, update: true }); + check("--update preserves customized obsolete file", existsSync(obsolete)); + check("customized obsolete file is reported customized", result.customized.includes(obsoleteRel)); +}); + +console.log(`\n${passed} checks passed, ${failures.length} failed.`); +if (failures.length) { + console.error("\nFailures:"); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} +console.log("Upgrade safety regression test passed."); From 6f55bec9d9bc6f310c8e141faba57ee9a8ea8bbd Mon Sep 17 00:00:00 2001 From: KingEmma Date: Fri, 7 Aug 2026 21:53:06 +0000 Subject: [PATCH 4/6] test(installer): run upgrade safety regression suite --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index d3a5e39..79b3bd2 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,13 @@ "doctor": "node scripts/init.mjs doctor", "detect": "node scripts/init.mjs detect", "pack:dry-run": "npm pack --dry-run", - "test": "node scripts/smoke-test.mjs" + "test": "node scripts/smoke-test.mjs && node scripts/upgrade-smoke-test.mjs" }, "files": [ "template", "scripts/init.mjs", "scripts/detect.mjs", + "scripts/upgrade-smoke-test.mjs", "examples", "README.md", "LICENSE", From 5cbd85339eba101017cfb63812f2382b84f95577 Mon Sep 17 00:00:00 2001 From: KingEmma Date: Fri, 7 Aug 2026 21:57:00 +0000 Subject: [PATCH 5/6] fix(cli): preserve doctor output compatibility --- scripts/init.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/init.mjs b/scripts/init.mjs index ba3f9ce..a66ae8d 100755 --- a/scripts/init.mjs +++ b/scripts/init.mjs @@ -610,13 +610,13 @@ function runDoctor(args) { if (result.staleManaged > 0) console.log(` note: ${result.staleManaged} managed file(s) can be refreshed with init --update.`); if (result.obsolete.length > 0) console.log(` note: ${result.obsolete.length} managed path(s) are no longer in the template.`); if (result.markerVersion && result.markerVersion !== version) { - console.log(` note: version marker says ${result.markerVersion}; current package is ${version}.`); + console.log(` note: installed from cursor-os ${result.markerVersion}; current is ${version}.`); } console.log(""); const broken = result.missingRequired > 0 || result.conflicts > 0 || result.manifestStatus === "invalid"; if (broken) { - console.log(`Cursor OS needs attention (${result.missingRequired} required missing, ${result.conflicts} conflict(s)).`); + console.log(`Cursor OS is not fully installed (${result.missingRequired} required file(s) missing, ${result.conflicts} conflict(s)). Run: cursor-os init`); process.exitCode = 1; } else if (result.todoCount > 0) { console.log("Cursor OS is installed. Run prompts/localize-cursor-os.md to complete setup."); From 84f0ba269e08972a9660839fad74be2f2f66b241 Mon Sep 17 00:00:00 2001 From: KingEmma Date: Fri, 7 Aug 2026 21:57:28 +0000 Subject: [PATCH 6/6] chore(pages): use current artifact action --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e014217..70b73ae 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -24,7 +24,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/configure-pages@v5 - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@v4 with: path: demo - id: deployment