diff --git a/.changeset/config.json b/.changeset/config.json
index caead5d..bb66bd8 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -7,5 +7,8 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
- "ignore": ["oidc-devtools", "@wolfcola/e2e", "@wolfcola/devtools-ui"]
+ "ignore": ["oidc-devtools", "@wolfcola/e2e", "@wolfcola/devtools-ui"],
+ "privatePackages": {
+ "version": true
+ }
}
diff --git a/.changeset/sync-manifest-version.md b/.changeset/sync-manifest-version.md
new file mode 100644
index 0000000..0f11bac
--- /dev/null
+++ b/.changeset/sync-manifest-version.md
@@ -0,0 +1,7 @@
+---
+'@wolfcola/devtools-extension': patch
+---
+
+Automate manifest.json version sync: after `changeset version` bumps
+package.json, the new `sync-manifest` CLI copies the version into
+manifest.json so Chrome Web Store publishes show real version numbers.
diff --git a/docs/superpowers/specs/2026-05-12-changeset-sync-manifest-design.md b/docs/superpowers/specs/2026-05-12-changeset-sync-manifest-design.md
new file mode 100644
index 0000000..b787b96
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-12-changeset-sync-manifest-design.md
@@ -0,0 +1,62 @@
+# @wolfcola/changeset-sync-manifest Design
+
+**Problem:** The Chrome extension `manifest.json` version is hardcoded at `0.1.0` and never bumped by changesets. Every CWS publish overwrites the same display version.
+
+**Solution:** A small CLI package that runs after `changeset version` and copies the `package.json` version into `manifest.json` for a given package directory.
+
+## Package
+
+- **Name:** `@wolfcola/changeset-sync-manifest`
+- **Location:** `packages/changeset-sync-manifest`
+- **Private:** yes
+
+### Files
+
+| File | Purpose |
+| ---------------------- | -------------------------------------------------------------------- |
+| `package.json` | Package manifest with `bin` entry |
+| `bin/sync-manifest.js` | Compiled CLI entry point |
+| `src/sync.ts` | Pure function: read `package.json` version, write to `manifest.json` |
+| `src/sync.test.ts` | Unit tests |
+
+### CLI Interface
+
+```
+sync-manifest
+```
+
+- `` — path to a package directory containing both `package.json` and `manifest.json`
+- Reads `/package.json` → extracts `version`
+- Reads `/manifest.json` → sets `version` field → writes back
+- Exits non-zero if either file is missing or JSON is malformed
+
+### Pure Function
+
+```ts
+syncManifestVersion(dir: string): void
+```
+
+Reads `package.json` and `manifest.json` from `dir`, copies the version, writes `manifest.json` back with the updated version. Preserves existing formatting (2-space indent, trailing newline).
+
+## Integration
+
+### Changesets config (`.changeset/config.json`)
+
+- Add `"privatePackages": { "version": true }` so changesets bumps private packages
+- Remove `@wolfcola/devtools-extension` from `ignore` so it participates in the `@wolfcola/*` fixed group
+
+### Version script (root `package.json`)
+
+```
+"version": "changeset version && sync-manifest packages/devtools-extension && prettier --write '**/package.json' pnpm-workspace.yaml"
+```
+
+### Build pipeline (unchanged)
+
+`build.mjs` continues reading `manifest.json` and calling `stampVersion()` to append the CI build number as the 4th version segment. The only difference is that the base version in `manifest.json` now reflects the real package version instead of a hardcoded `0.1.0`.
+
+## What this does NOT do
+
+- Does not handle the VS Code extension (its `package.json` is its manifest — changesets handles it directly if removed from `ignore`)
+- Does not scan the workspace automatically — takes an explicit directory argument
+- Does not handle jsonpath or arbitrary file targets — just `package.json` → `manifest.json` version sync
diff --git a/package.json b/package.json
index 26d089d..0920d65 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,7 @@
"test": "vitest run",
"typecheck": "tsc --build",
"changeset": "changeset",
- "version": "changeset version && prettier --write '**/package.json' pnpm-workspace.yaml",
+ "version": "changeset version && sync-manifest packages/devtools-extension && prettier --write '**/package.json' pnpm-workspace.yaml",
"release": "pnpm build && changeset publish",
"syncpack:lint": "syncpack lint",
"syncpack:fix": "syncpack fix-mismatches",
@@ -45,6 +45,7 @@
"syncpack": "^15.1.2",
"typescript": "5.8.3",
"vite": "catalog:vite",
+ "@wolfcola/changeset-sync-manifest": "workspace:*",
"vitest": "catalog:vitest"
},
"pnpm": {
diff --git a/packages/changeset-sync-manifest/package.json b/packages/changeset-sync-manifest/package.json
new file mode 100644
index 0000000..903b785
--- /dev/null
+++ b/packages/changeset-sync-manifest/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@wolfcola/changeset-sync-manifest",
+ "version": "0.0.0",
+ "description": "Sync package.json version into manifest.json after changeset version",
+ "license": "MIT",
+ "type": "module",
+ "private": true,
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ryanbas21/devtools.git",
+ "directory": "packages/changeset-sync-manifest"
+ },
+ "bin": {
+ "sync-manifest": "./dist/bin.js"
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.lib.json",
+ "lint": "eslint .",
+ "test": "vitest run"
+ },
+ "devDependencies": {
+ "vitest": "catalog:vitest"
+ }
+}
diff --git a/packages/changeset-sync-manifest/src/bin.ts b/packages/changeset-sync-manifest/src/bin.ts
new file mode 100644
index 0000000..bba8e03
--- /dev/null
+++ b/packages/changeset-sync-manifest/src/bin.ts
@@ -0,0 +1,18 @@
+#!/usr/bin/env node
+import { resolve } from 'node:path';
+import { syncManifestVersion } from './sync.js';
+
+const dir = process.argv[2];
+
+if (!dir) {
+ console.error('Usage: sync-manifest ');
+ process.exit(1);
+}
+
+try {
+ syncManifestVersion(resolve(dir));
+ console.log(`Synced manifest.json version in ${dir}`);
+} catch (e) {
+ console.error(String(e instanceof Error ? e.message : e));
+ process.exit(1);
+}
diff --git a/packages/changeset-sync-manifest/src/sync.test.ts b/packages/changeset-sync-manifest/src/sync.test.ts
new file mode 100644
index 0000000..e311771
--- /dev/null
+++ b/packages/changeset-sync-manifest/src/sync.test.ts
@@ -0,0 +1,75 @@
+import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { syncManifestVersion } from './sync.js';
+
+describe('syncManifestVersion', () => {
+ let dir: string;
+
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'sync-manifest-'));
+ });
+
+ afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+ });
+
+ it('copies version from package.json to manifest.json', () => {
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ version: '2.0.0' }));
+ writeFileSync(
+ join(dir, 'manifest.json'),
+ JSON.stringify({ manifest_version: 3, version: '0.1.0' }),
+ );
+
+ syncManifestVersion(dir);
+
+ const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'));
+ expect(manifest.version).toBe('2.0.0');
+ });
+
+ it('preserves other manifest fields', () => {
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ version: '1.0.0' }));
+ writeFileSync(
+ join(dir, 'manifest.json'),
+ JSON.stringify({ manifest_version: 3, name: 'My Extension', version: '0.1.0' }),
+ );
+
+ syncManifestVersion(dir);
+
+ const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'));
+ expect(manifest.manifest_version).toBe(3);
+ expect(manifest.name).toBe('My Extension');
+ expect(manifest.version).toBe('1.0.0');
+ });
+
+ it('writes with 2-space indent and trailing newline', () => {
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ version: '1.0.0' }));
+ writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ version: '0.1.0' }));
+
+ syncManifestVersion(dir);
+
+ const raw = readFileSync(join(dir, 'manifest.json'), 'utf8');
+ expect(raw).toContain(' "version"');
+ expect(raw.endsWith('\n')).toBe(true);
+ });
+
+ it('throws if package.json is missing', () => {
+ writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ version: '0.1.0' }));
+
+ expect(() => syncManifestVersion(dir)).toThrow('package.json');
+ });
+
+ it('throws if manifest.json is missing', () => {
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ version: '1.0.0' }));
+
+ expect(() => syncManifestVersion(dir)).toThrow('manifest.json');
+ });
+
+ it('throws if package.json has no version field', () => {
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'test' }));
+ writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ version: '0.1.0' }));
+
+ expect(() => syncManifestVersion(dir)).toThrow('version');
+ });
+});
diff --git a/packages/changeset-sync-manifest/src/sync.ts b/packages/changeset-sync-manifest/src/sync.ts
new file mode 100644
index 0000000..530acdd
--- /dev/null
+++ b/packages/changeset-sync-manifest/src/sync.ts
@@ -0,0 +1,31 @@
+import { readFileSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+export const syncManifestVersion = (dir: string): void => {
+ const pkgPath = join(dir, 'package.json');
+ const manifestPath = join(dir, 'manifest.json');
+
+ let pkgRaw: string;
+ try {
+ pkgRaw = readFileSync(pkgPath, 'utf8');
+ } catch {
+ throw new Error(`Cannot read package.json at ${pkgPath}`);
+ }
+
+ let manifestRaw: string;
+ try {
+ manifestRaw = readFileSync(manifestPath, 'utf8');
+ } catch {
+ throw new Error(`Cannot read manifest.json at ${manifestPath}`);
+ }
+
+ const pkg = JSON.parse(pkgRaw) as { version?: string };
+ if (!pkg.version) {
+ throw new Error(`No version field in ${pkgPath}`);
+ }
+
+ const manifest = JSON.parse(manifestRaw) as Record;
+ manifest.version = pkg.version;
+
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
+};
diff --git a/packages/changeset-sync-manifest/tsconfig.json b/packages/changeset-sync-manifest/tsconfig.json
new file mode 100644
index 0000000..f6a4a19
--- /dev/null
+++ b/packages/changeset-sync-manifest/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo",
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "verbatimModuleSyntax": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "references": [],
+ "exclude": ["vitest.config.mts", "src/**/*.test.ts"]
+}
diff --git a/packages/changeset-sync-manifest/tsconfig.lib.json b/packages/changeset-sync-manifest/tsconfig.lib.json
new file mode 100644
index 0000000..2d042cc
--- /dev/null
+++ b/packages/changeset-sync-manifest/tsconfig.lib.json
@@ -0,0 +1,6 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "emitDeclarationOnly": false
+ }
+}
diff --git a/packages/changeset-sync-manifest/vitest.config.mts b/packages/changeset-sync-manifest/vitest.config.mts
new file mode 100644
index 0000000..b107878
--- /dev/null
+++ b/packages/changeset-sync-manifest/vitest.config.mts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig(() => ({
+ root: __dirname,
+ cacheDir: '../../node_modules/.vite/packages/changeset-sync-manifest',
+ test: {
+ name: 'changeset-sync-manifest',
+ watch: false,
+ globals: true,
+ environment: 'node',
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+ reporters: ['default'],
+ },
+}));
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7139fce..e2e6ad5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -63,6 +63,9 @@ importers:
'@typescript-eslint/parser':
specifier: ^8.45.0
version: 8.59.2(eslint@9.39.4)(typescript@5.8.3)
+ '@wolfcola/changeset-sync-manifest':
+ specifier: workspace:*
+ version: link:packages/changeset-sync-manifest
eslint:
specifier: ^9.8.0
version: 9.39.4
@@ -137,6 +140,12 @@ importers:
specifier: catalog:vitest
version: 3.2.4(@types/node@22.19.18)(jsdom@26.1.0)(terser@5.47.1)(yaml@2.9.0)
+ packages/changeset-sync-manifest:
+ devDependencies:
+ vitest:
+ specifier: catalog:vitest
+ version: 3.2.4(@types/node@22.19.18)(jsdom@26.1.0)(terser@5.47.1)(yaml@2.9.0)
+
packages/devtools-bridge:
dependencies:
'@forgerock/davinci-client':
diff --git a/tsconfig.json b/tsconfig.json
index 91b66bc..3840608 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,6 +7,7 @@
{ "path": "packages/devtools-extension" },
{ "path": "packages/vscode-extension" },
{ "path": "packages/treeshake-check" },
- { "path": "packages/eslint-plugin-treeshake" }
+ { "path": "packages/eslint-plugin-treeshake" },
+ { "path": "packages/changeset-sync-manifest" }
]
}