version-history-widget 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,27 @@ Or use without installing:
21
21
  npx version-history-widget init
22
22
  ```
23
23
 
24
+ ## Auto-recording (so nothing is ever missed)
25
+
26
+ A version only exists if something records it. Rather than relying on you
27
+ (or your AI assistant) to remember, let it record on its own:
28
+
29
+ ```bash
30
+ npx vh watch # auto-saves every change as a new version, debounced
31
+ ```
32
+
33
+ Run that alongside whatever dev server you use — Vite, Next, Astro, plain
34
+ HTML, anything. A burst of edits becomes one version, not twenty.
35
+
36
+ You usually don't even need to run it: **`.versions/middleware.js` starts the
37
+ same watcher automatically whenever it's mounted**, so `node .versions/serve.js`
38
+ or a dev server with the middleware mounted records changes on its own. For
39
+ Vite projects, `vh init` writes a `vite.config.js` that mounts it for you (an
40
+ existing config is never modified — it just prints the snippet to add).
41
+
42
+ Explicitly running `vh record "Some title"` is still worth it when you want a
43
+ meaningful title instead of an auto-generated "Updated src/main.js".
44
+
24
45
  ## Project mode
25
46
 
26
47
  ```bash
@@ -28,6 +49,7 @@ npx vh init # sets up .versions/, injects the widget, saves vers
28
49
  npx vh record "Title" -d "what changed and why"
29
50
  npx vh restore 4 # safety-snapshots current state, then restores version 4
30
51
  npx vh list [query] # list versions, optionally filtered
52
+ npx vh watch # auto-save every change as a new version
31
53
  npx vh update # refresh .versions/*.js from the currently installed package
32
54
  ```
33
55
 
package/middleware.js CHANGED
@@ -1,11 +1,18 @@
1
- /* Connect/Express/Vite-compatible middleware: serves /.versions/* and handles POST /__vh/restore/:id
1
+ /* Connect/Express/Vite-compatible middleware: serves /.versions/*, handles POST /__vh/restore/:id,
2
+ and auto-records every change as a new version while the dev server is running.
2
3
  Express: app.use(require('./.versions/middleware.js')())
3
4
  Vite: plugins:[{ name:'vh', configureServer(s){ s.middlewares.use(require('./.versions/middleware.js')()) } }]
4
- Next: use serve.js alongside, or add a route handler that calls require('./.versions/vh.js').restore(id) */
5
+ Next: use serve.js alongside, or add a route handler that calls require('./.versions/vh.js').restore(id)
6
+ Pass { watch: false } to opt out of auto-recording. */
5
7
  const fs = require('fs'), path = require('path');
6
8
  module.exports = function (opts = {}) {
7
9
  const root = path.resolve(opts.root || process.env.VH_ROOT || process.cwd()), vdir = path.join(root, '.versions');
8
10
  const types = { '.js': 'text/javascript', '.json': 'application/json', '.diff': 'text/plain' };
11
+ if (opts.watch !== false && fs.existsSync(vdir)) {
12
+ process.env.VH_ROOT = root; // vh.js resolves its root from this at require time
13
+ try { require(path.join(vdir, 'vh.js')).watch({ keepAlive: false }); }
14
+ catch (e) { console.error('[vh] auto-record unavailable: ' + e.message); }
15
+ }
9
16
  return function (req, res, next) {
10
17
  const url = (req.url || '').split('?')[0];
11
18
  if (req.method === 'POST' && url.startsWith('/__vh/restore/')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "version-history-widget",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Zero-dependency version history for web projects: snapshots every change and adds a floating widget to browse, search, and restore any version.",
5
5
  "keywords": ["version history", "undo", "snapshot", "restore", "dev-tool", "widget"],
6
6
  "license": "MIT",
package/skill/SKILL.md CHANGED
@@ -14,6 +14,23 @@ Two modes — pick based on what the user is working on:
14
14
  - **Project mode**: a normal multi-file site/app. History lives in `.versions/`.
15
15
  - **Single-file mode**: one standalone `.html` file. History lives inside that file.
16
16
 
17
+ ## Record after you change things
18
+
19
+ **If a project has a `.versions/` directory, it has version history enabled,
20
+ and every meaningful change you make should end with a recorded version.**
21
+ Don't leave the user's history stuck on an old snapshot — after editing
22
+ files, run `record` with a short human title describing what you changed:
23
+
24
+ `npx version-history-widget record "Tweak icon animation timing" -d "what changed and why"`
25
+
26
+ If the project is set up for auto-recording (`vh watch`, or the middleware
27
+ mounted in their dev server), versions are captured on their own and you
28
+ don't need to record manually — but recording explicitly is still better,
29
+ since it gives the version a meaningful title instead of "Updated src/main.js".
30
+
31
+ To set a project up so changes are never missed again, see "Auto-recording"
32
+ below.
33
+
17
34
  ## Project mode
18
35
 
19
36
  Run these with Bash from the project root (`npx` needs no local install):
@@ -35,6 +52,40 @@ Run these with Bash from the project root (`npx` needs no local install):
35
52
 
36
53
  If `.versions/` doesn't exist yet in the project, run `init` first.
37
54
 
55
+ ## Auto-recording
56
+
57
+ So that changes are never silently lost, a project can record versions on
58
+ its own. Two ways, both framework-agnostic:
59
+
60
+ - Run a watcher alongside whatever dev server they use:
61
+ `npx version-history-widget watch`
62
+ It debounces, so a burst of edits becomes one version.
63
+ - Or mount the middleware in their dev server, which starts the same
64
+ watcher automatically whenever the server runs. For Vite, a
65
+ `vite.config.js` containing:
66
+
67
+ ```js
68
+ import { createRequire } from 'module';
69
+ const require = createRequire(import.meta.url);
70
+
71
+ export default {
72
+ plugins: [{
73
+ name: 'version-history',
74
+ configureServer(server) {
75
+ server.middlewares.use(require('./.versions/middleware.js')());
76
+ },
77
+ }],
78
+ };
79
+ ```
80
+
81
+ (`createRequire` is needed because Vite configs are ES modules and the
82
+ vendored `.versions/` files are CommonJS.)
83
+
84
+ If the user reports that a change they made isn't showing up in the widget,
85
+ it is almost always because nothing recorded it — check
86
+ `.versions/manifest.json` against the current files, record the missing
87
+ version, then set up one of the two options above.
88
+
38
89
  ## Single-file mode
39
90
 
40
91
  Use when the user names one `.html` file rather than a project:
package/vh.js CHANGED
@@ -4,6 +4,8 @@
4
4
  node vh.js record "Title" [-d "details" | -f details.txt] — snapshot changed files as a new version
5
5
  node vh.js restore <id> — snapshot current state, then restore version <id>
6
6
  node vh.js list [query] — list versions (optionally filtered by title/details)
7
+ node vh.js watch — auto-record every change as a new version (run alongside
8
+ your dev server; also runs inside serve.js/middleware.js)
7
9
  node vh.js update — refresh the vendored widget.js/vh.js/serve.js/middleware.js
8
10
  in .versions/ from the installed package (run after upgrading)
9
11
  node vh.js skill — install the Claude Code skill (~/.claude/skills) so
@@ -12,7 +14,8 @@
12
14
  const fs = require('fs'), path = require('path'), os = require('os');
13
15
  const ROOT = path.resolve(process.env.VH_ROOT || process.cwd());
14
16
  const VDIR = path.join(ROOT, '.versions'), SNAP = path.join(VDIR, 'snapshots'), MAN = path.join(VDIR, 'manifest.json');
15
- const IGNORE = new Set(['node_modules', '.git', '.versions', 'dist', 'build', '.next', '.nuxt', 'coverage', '.cache']);
17
+ const LOCK = path.join(VDIR, '.restoring');
18
+ const IGNORE = new Set(['node_modules', '.git', '.versions', 'dist', 'build', '.next', '.nuxt', 'coverage', '.cache', '.DS_Store', '.claude']);
16
19
  const MAX = 2 * 1024 * 1024;
17
20
 
18
21
  const readMan = () => fs.existsSync(MAN) ? JSON.parse(fs.readFileSync(MAN, 'utf8')) : [];
@@ -86,19 +89,146 @@ function injectWidget() {
86
89
  return null;
87
90
  }
88
91
 
92
+ const VITE_CONFIG = `import { createRequire } from 'module';
93
+ const require = createRequire(import.meta.url);
94
+
95
+ export default {
96
+ plugins: [{
97
+ name: 'version-history',
98
+ configureServer(server) {
99
+ server.middlewares.use(require('./.versions/middleware.js')());
100
+ },
101
+ }],
102
+ };
103
+ `;
104
+
105
+ function usesVite() {
106
+ const pj = path.join(ROOT, 'package.json');
107
+ if (!fs.existsSync(pj)) return false;
108
+ try {
109
+ const p = JSON.parse(fs.readFileSync(pj, 'utf8'));
110
+ if ((p.devDependencies && p.devDependencies.vite) || (p.dependencies && p.dependencies.vite)) return true;
111
+ return /\bvite\b/.test(Object.values(p.scripts || {}).join(' '));
112
+ } catch (e) { return false; }
113
+ }
114
+
115
+ function viteConfigPath() {
116
+ for (const f of ['vite.config.js', 'vite.config.mjs', 'vite.config.ts', 'vite.config.mts', 'vite.config.cjs'])
117
+ if (fs.existsSync(path.join(ROOT, f))) return path.join(ROOT, f);
118
+ return null;
119
+ }
120
+
121
+ /* Wire the dev server up so changes keep getting auto-recorded. Only ever
122
+ creates a config when there isn't one — an existing config is never edited,
123
+ since that's the user's file. Re-run after a restore so restoring to a
124
+ version that predates the setup doesn't silently switch auto-saving off. */
125
+ function ensureDevIntegration(quiet) {
126
+ if (!usesVite()) return null;
127
+ const existing = viteConfigPath();
128
+ if (existing) {
129
+ if (!quiet && !fs.readFileSync(existing, 'utf8').includes('.versions/middleware.js'))
130
+ console.log('Note: ' + path.basename(existing) + ' does not mount .versions/middleware.js, so changes are only auto-recorded while `vh watch` is running. See the README for the snippet to add.');
131
+ return null;
132
+ }
133
+ const p = path.join(ROOT, 'vite.config.js');
134
+ fs.writeFileSync(p, VITE_CONFIG);
135
+ if (!quiet) console.log('Created vite.config.js mounting .versions/middleware.js — restart your dev server and every change is auto-recorded.');
136
+ return p;
137
+ }
138
+
139
+ /* Files that differ from the newest recorded version. */
140
+ function pendingChanges() {
141
+ const man = readMan(), prev = man.length ? stateAt(man[man.length - 1].id, man) : {};
142
+ const cur = walk(ROOT), out = [];
143
+ for (const f of Object.keys(cur)) { const old = prev[f]; if (!old || !old.equals(cur[f])) out.push(f); }
144
+ for (const f of Object.keys(prev)) if (!cur[f]) out.push(f);
145
+ return out;
146
+ }
147
+
148
+ /* Record a version in-process. Returns null when nothing changed. */
149
+ function recordNow(title, details) {
150
+ const man = readMan(), prev = man.length ? stateAt(man[man.length - 1].id, man) : {};
151
+ const r = snapshot(title, details, prev);
152
+ if (!r.changed) { const m = readMan(); m.pop(); writeMan(m); fs.rmSync(path.join(SNAP, String(r.id)), { recursive: true, force: true }); return null; }
153
+ return r;
154
+ }
155
+
156
+ function autoTitle(files) {
157
+ if (files.length === 1) return 'Updated ' + files[0];
158
+ return 'Updated ' + files[0] + ' and ' + (files.length - 1) + ' more file' + (files.length > 2 ? 's' : '');
159
+ }
160
+
161
+ function ignoredPath(rel) {
162
+ if (!rel) return false;
163
+ for (const seg of String(rel).split(/[\\/]/)) if (IGNORE.has(seg) || seg.startsWith('.env')) return true;
164
+ return false;
165
+ }
166
+
167
+ /* Auto-record changes as they happen. Debounced so a burst of edits (an editor
168
+ or an agent writing several files) becomes one version, not twenty. */
169
+ function startWatch(opts = {}) {
170
+ const wait = opts.debounce || 1500, log = opts.log !== false;
171
+ let timer = null, busy = false;
172
+ function fire() {
173
+ timer = null;
174
+ if (busy || fs.existsSync(LOCK)) return schedule(); // mid-restore: check again later
175
+ busy = true;
176
+ try {
177
+ const files = pendingChanges();
178
+ if (files.length) {
179
+ const title = autoTitle(files);
180
+ const r = recordNow(title, 'Auto-saved.\n\nChanged files:\n' + files.map(f => ' ' + f).join('\n'));
181
+ if (r && log) console.log('[vh] saved version ' + r.id + ': ' + title);
182
+ }
183
+ } catch (e) { if (log) console.error('[vh] auto-save failed: ' + e.message); }
184
+ finally { busy = false; }
185
+ }
186
+ function schedule() { clearTimeout(timer); timer = setTimeout(fire, wait); }
187
+
188
+ let watcher = null;
189
+ try { watcher = fs.watch(ROOT, { recursive: true }, (evt, name) => { if (!ignoredPath(name)) schedule(); }); }
190
+ catch (e) { watcher = null; } // recursive watch unsupported (older Linux)
191
+ let poller = null;
192
+ if (!watcher) {
193
+ let seen = 0;
194
+ poller = setInterval(() => {
195
+ let newest = 0;
196
+ (function scan(dir) {
197
+ let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
198
+ for (const e of entries) {
199
+ if (IGNORE.has(e.name) || e.name.startsWith('.env')) continue;
200
+ const p = path.join(dir, e.name);
201
+ if (e.isDirectory()) scan(p);
202
+ else { try { const m = fs.statSync(p).mtimeMs; if (m > newest) newest = m; } catch (e2) { } }
203
+ }
204
+ })(ROOT);
205
+ if (newest > seen) { seen = newest; schedule(); }
206
+ }, opts.poll || 2000);
207
+ }
208
+ if (opts.keepAlive === false) { if (watcher && watcher.unref) watcher.unref(); if (poller && poller.unref) poller.unref(); }
209
+ schedule(); // catch up on anything changed while nothing was watching
210
+ return { stop() { clearTimeout(timer); if (watcher) watcher.close(); if (poller) clearInterval(poller); } };
211
+ }
212
+
89
213
  function vendorFiles() {
90
214
  fs.mkdirSync(VDIR, { recursive: true });
91
215
  for (const f of ['widget.js', 'vh.js', 'serve.js', 'middleware.js']) { const src = path.join(__dirname, f); if (fs.existsSync(src) && src !== path.join(VDIR, f)) fs.copyFileSync(src, path.join(VDIR, f)); }
216
+ // Force CommonJS for everything under .versions/, regardless of the host
217
+ // project's own package.json — otherwise a host with "type":"module" makes
218
+ // Node treat these vendored .js files as ESM and `require` disappears.
219
+ fs.writeFileSync(path.join(VDIR, 'package.json'), JSON.stringify({ type: 'commonjs' }, null, 2) + '\n');
92
220
  }
93
221
 
94
- const cmd = process.argv[2], args = process.argv.slice(3);
95
- if (cmd === 'init') {
222
+ const cmd = require.main === module ? process.argv[2] : null, args = process.argv.slice(3);
223
+ if (cmd === null) { // required as a library (serve.js / middleware.js)
224
+ } else if (cmd === 'init') {
96
225
  fs.mkdirSync(SNAP, { recursive: true });
97
226
  vendorFiles();
98
227
  if (!fs.existsSync(MAN)) writeMan([]);
99
228
  const gi = path.join(ROOT, '.gitignore'); const line = '.versions/snapshots/';
100
229
  if (!fs.existsSync(gi) || !fs.readFileSync(gi, 'utf8').includes(line)) fs.appendFileSync(gi, '\n' + line + '\n');
101
230
  const where = injectWidget();
231
+ ensureDevIntegration(false);
102
232
  const r = readMan().length ? null : snapshot('Initial version', 'State of the project when version history was enabled.', {});
103
233
  console.log('version-history ready.' + (where ? ' Widget injected into ' + path.relative(ROOT, where) + '.' : ' Could not find an entry HTML — add <script src="/.versions/widget.js"></script> manually.') + (r ? ' Saved version 1.' : ''));
104
234
  console.log('Serve with restore endpoint: node .versions/serve.js (or mount .versions/middleware.js in your dev server)');
@@ -106,21 +236,27 @@ if (cmd === 'init') {
106
236
  const title = args[0]; if (!title) { console.error('usage: vh record "Title" [-d "details" | -f file]'); process.exit(1); }
107
237
  const di = args.indexOf('-d'), fi = args.indexOf('-f');
108
238
  const details = di >= 0 ? args[di + 1] : fi >= 0 ? fs.readFileSync(args[fi + 1], 'utf8') : '';
109
- const man = readMan(), prev = man.length ? stateAt(man[man.length - 1].id, man) : {};
110
- const r = snapshot(title, details, prev);
111
- console.log(r.changed ? 'Saved as version ' + r.id + ': ' + title : 'No changes since last version — nothing saved.');
112
- if (!r.changed) { const m = readMan(); m.pop(); writeMan(m); fs.rmSync(path.join(SNAP, String(r.id)), { recursive: true, force: true }); }
239
+ const r = recordNow(title, details);
240
+ console.log(r ? 'Saved as version ' + r.id + ': ' + title : 'No changes since last version — nothing saved.');
113
241
  } else if (cmd === 'restore') {
114
242
  const id = Number(args[0]), man = readMan(), target = man.find(v => v.id === id);
115
243
  if (!target) { console.error('No version ' + args[0]); process.exit(1); }
116
- const cur = man[man.length - 1], prev = stateAt(cur.id, man);
117
- snapshot('Snapshot before restoring "' + target.title + '"', 'Automatic safety snapshot taken before restore.', prev);
118
- const want = stateAt(id, readMan()), have = walk(ROOT);
119
- for (const f of Object.keys(have)) if (!want[f]) fs.rmSync(path.join(ROOT, f));
120
- for (const f of Object.keys(want)) { const p = path.join(ROOT, f); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, want[f]); }
121
- injectWidget();
122
- const r = snapshot('Restored "' + target.title + '"', 'Restored the state saved as version ' + id + '.', stateAt(readMan().slice(-1)[0].id, readMan()));
123
- console.log('Restored version ' + id + ' (recorded as version ' + r.id + ').');
244
+ fs.mkdirSync(VDIR, { recursive: true }); fs.writeFileSync(LOCK, String(process.pid)); // pause any watcher
245
+ try {
246
+ const cur = man[man.length - 1], prev = stateAt(cur.id, man);
247
+ snapshot('Snapshot before restoring "' + target.title + '"', 'Automatic safety snapshot taken before restore.', prev);
248
+ const want = stateAt(id, readMan()), have = walk(ROOT);
249
+ for (const f of Object.keys(have)) if (!want[f]) fs.rmSync(path.join(ROOT, f));
250
+ for (const f of Object.keys(want)) { const p = path.join(ROOT, f); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, want[f]); }
251
+ injectWidget();
252
+ ensureDevIntegration(true);
253
+ const r = snapshot('Restored "' + target.title + '"', 'Restored the state saved as version ' + id + '.', stateAt(readMan().slice(-1)[0].id, readMan()));
254
+ console.log('Restored version ' + id + ' (recorded as version ' + r.id + ').');
255
+ } finally { fs.rmSync(LOCK, { force: true }); }
256
+ } else if (cmd === 'watch') {
257
+ if (!fs.existsSync(VDIR)) { console.error('No .versions/ here — run `vh init` first.'); process.exit(1); }
258
+ startWatch({});
259
+ console.log('Watching ' + ROOT + ' — every change is auto-saved as a new version. Ctrl-C to stop.');
124
260
  } else if (cmd === 'list') {
125
261
  const q = (args[0] || '').toLowerCase();
126
262
  for (const v of readMan()) if (!q || v.title.toLowerCase().includes(q) || (v.details || '').toLowerCase().includes(q)) console.log(String(v.id).padStart(3) + ' ' + v.time.slice(0, 16).replace('T', ' ') + ' ' + v.title);
@@ -134,4 +270,7 @@ if (cmd === 'init') {
134
270
  fs.copyFileSync(path.join(__dirname, 'skill', 'SKILL.md'), path.join(dest, 'SKILL.md'));
135
271
  console.log('Installed Claude Code skill to ' + dest + '. Restart Claude Code (or start a new session) and try /version-history-widget.');
136
272
  } else { console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0].split('\n').slice(1).join('\n')); }
137
- module.exports = { restore: id => { const { execFileSync } = require('child_process'); return execFileSync(process.execPath, [__filename, 'restore', String(id)], { cwd: ROOT, env: process.env }).toString(); } };
273
+ module.exports = {
274
+ restore: id => { const { execFileSync } = require('child_process'); return execFileSync(process.execPath, [__filename, 'restore', String(id)], { cwd: ROOT, env: process.env }).toString(); },
275
+ watch: startWatch, record: recordNow, pending: pendingChanges, root: ROOT,
276
+ };