@rune-kit/rune 2.12.0 → 2.12.2

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.
@@ -4,12 +4,14 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promis
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { afterEach, beforeEach, describe, test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
7
8
  import * as antigravity from '../adapters/hooks/antigravity.js';
8
9
  import { installHooks } from '../commands/hooks/install.js';
9
10
  import { hookStatus } from '../commands/hooks/status.js';
10
11
  import { uninstallHooks } from '../commands/hooks/uninstall.js';
11
12
 
12
- const RUNE_ROOT = path.resolve(import.meta.dirname, '..', '..');
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const RUNE_ROOT = path.resolve(__dirname, '..', '..');
13
15
  const RULES_DIR = '.antigravity/rules';
14
16
 
15
17
  let tmpRoot;
@@ -4,12 +4,14 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promis
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { afterEach, beforeEach, describe, test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
7
8
  import * as cursor from '../adapters/hooks/cursor.js';
8
9
  import { installHooks } from '../commands/hooks/install.js';
9
10
  import { hookStatus } from '../commands/hooks/status.js';
10
11
  import { uninstallHooks } from '../commands/hooks/uninstall.js';
11
12
 
12
- const RUNE_ROOT = path.resolve(import.meta.dirname, '..', '..');
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const RUNE_ROOT = path.resolve(__dirname, '..', '..');
13
15
  const RULES_DIR = '.cursor/rules';
14
16
 
15
17
  let tmpRoot;
@@ -4,12 +4,14 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { afterEach, beforeEach, describe, test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
7
8
  import { installHooks } from '../commands/hooks/install.js';
8
9
  import { SETTINGS_REL_PATH } from '../commands/hooks/presets.js';
9
10
  import { hookStatus } from '../commands/hooks/status.js';
10
11
  import { uninstallHooks } from '../commands/hooks/uninstall.js';
11
12
 
12
- const RUNE_ROOT = path.resolve(import.meta.dirname, '..', '..');
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const RUNE_ROOT = path.resolve(__dirname, '..', '..');
13
15
 
14
16
  let tmpRoot;
15
17
 
@@ -18,18 +18,25 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promis
18
18
  import { tmpdir } from 'node:os';
19
19
  import path from 'node:path';
20
20
  import { afterEach, beforeEach, describe, test } from 'node:test';
21
+ import { fileURLToPath } from 'node:url';
21
22
  import { installHooks } from '../commands/hooks/install.js';
22
23
  import { SETTINGS_REL_PATH } from '../commands/hooks/presets.js';
23
24
  import { hookStatus } from '../commands/hooks/status.js';
24
25
  import {
26
+ _resetFreeVersionCache,
27
+ assertFreeVersionCompat,
25
28
  checkManifestRequires,
29
+ compareSemver,
30
+ getFreeVersion,
26
31
  loadTierManifest,
27
32
  locateTierManifest,
33
+ parseSemver,
28
34
  resolveTier,
29
35
  validateManifest,
30
36
  } from '../commands/hooks/tiers.js';
31
37
 
32
- const RUNE_ROOT = path.resolve(import.meta.dirname, '..', '..');
38
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
+ const RUNE_ROOT = path.resolve(__dirname, '..', '..');
33
40
 
34
41
  let tmpRoot;
35
42
  let tierRoot;
@@ -494,6 +501,100 @@ describe('review fixes: M3 overrides consumption', () => {
494
501
  });
495
502
  });
496
503
 
504
+ describe('Gap 2: minFreeVersion version gate', () => {
505
+ test('parseSemver accepts x.y.z and ignores prerelease/build', () => {
506
+ assert.deepStrictEqual(parseSemver('2.12.0'), [2, 12, 0]);
507
+ assert.deepStrictEqual(parseSemver('2.12.0-rc.1'), [2, 12, 0]);
508
+ assert.deepStrictEqual(parseSemver('2.12.0+sha.abc'), [2, 12, 0]);
509
+ assert.strictEqual(parseSemver('not-a-version'), null);
510
+ assert.strictEqual(parseSemver(null), null);
511
+ });
512
+
513
+ test('compareSemver returns -1/0/1 ordering', () => {
514
+ assert.strictEqual(compareSemver('2.12.0', '2.12.0'), 0);
515
+ assert.strictEqual(compareSemver('2.11.9', '2.12.0'), -1);
516
+ assert.strictEqual(compareSemver('2.12.1', '2.12.0'), 1);
517
+ assert.strictEqual(compareSemver('3.0.0', '2.12.0'), 1);
518
+ assert.strictEqual(compareSemver('bogus', '2.12.0'), null);
519
+ });
520
+
521
+ test('validateManifest accepts optional minFreeVersion', () => {
522
+ const m = validateManifest({ ...PRO_MANIFEST_FIXTURE, minFreeVersion: '2.12.0' });
523
+ assert.strictEqual(m.minFreeVersion, '2.12.0');
524
+ });
525
+
526
+ test('validateManifest rejects non-semver minFreeVersion', () => {
527
+ assert.throws(
528
+ () => validateManifest({ ...PRO_MANIFEST_FIXTURE, minFreeVersion: 'latest' }),
529
+ /minFreeVersion.*must be semver/,
530
+ );
531
+ });
532
+
533
+ test('validateManifest rejects empty minFreeVersion string', () => {
534
+ assert.throws(() => validateManifest({ ...PRO_MANIFEST_FIXTURE, minFreeVersion: '' }), /minFreeVersion.*non-empty/);
535
+ });
536
+
537
+ test('assertFreeVersionCompat is a no-op when minFreeVersion unset', () => {
538
+ const m = validateManifest(PRO_MANIFEST_FIXTURE);
539
+ assert.doesNotThrow(() => assertFreeVersionCompat(m, '0.0.1'));
540
+ });
541
+
542
+ test('assertFreeVersionCompat passes when current >= minFreeVersion', () => {
543
+ const m = validateManifest({ ...PRO_MANIFEST_FIXTURE, minFreeVersion: '2.12.0' });
544
+ assert.doesNotThrow(() => assertFreeVersionCompat(m, '2.12.0'));
545
+ assert.doesNotThrow(() => assertFreeVersionCompat(m, '2.12.1'));
546
+ assert.doesNotThrow(() => assertFreeVersionCompat(m, '3.0.0'));
547
+ });
548
+
549
+ test('assertFreeVersionCompat throws helpful upgrade error when current < minFreeVersion', () => {
550
+ const m = validateManifest({ ...PRO_MANIFEST_FIXTURE, minFreeVersion: '2.12.0' });
551
+ assert.throws(
552
+ () => assertFreeVersionCompat(m, '2.11.0'),
553
+ (err) => {
554
+ assert.match(err.message, /requires Rune Free >= 2\.12\.0/);
555
+ assert.match(err.message, /installed compiler is 2\.11\.0/);
556
+ assert.match(err.message, /npm i -g @rune-kit\/rune@latest/);
557
+ assert.match(err.message, /--tier pro/);
558
+ return true;
559
+ },
560
+ );
561
+ });
562
+
563
+ test('resolveTier enforces minFreeVersion via assertFreeVersionCompat', async () => {
564
+ const futureManifest = { ...PRO_MANIFEST_FIXTURE, minFreeVersion: '999.0.0' };
565
+ await writeFile(path.join(tierRoot, 'hooks', 'manifest.json'), JSON.stringify(futureManifest, null, 2));
566
+ await assert.rejects(resolveTier('pro', tmpRoot), /requires Rune Free >= 999\.0\.0/);
567
+ });
568
+
569
+ test('getFreeVersion reads package.json semver', () => {
570
+ _resetFreeVersionCache();
571
+ const v = getFreeVersion();
572
+ assert.match(v, /^\d+\.\d+\.\d+/);
573
+ });
574
+ });
575
+
576
+ describe('Gap 3: actionable missing-manifest error', () => {
577
+ test('resolveTier error lists both env var and monorepo sibling path', async () => {
578
+ delete process.env.RUNE_PRO_ROOT;
579
+ await assert.rejects(resolveTier('pro', tmpRoot), (err) => {
580
+ assert.match(err.message, /RUNE_PRO_ROOT/);
581
+ assert.match(err.message, /monorepo sibling/);
582
+ assert.match(err.message, /Drop --tier pro/);
583
+ assert.match(err.message, /rune\.dev\/docs\/hooks/);
584
+ return true;
585
+ });
586
+ });
587
+
588
+ test('resolveTier error for unknown tier (no env var) still includes sibling + docs hint', async () => {
589
+ await assert.rejects(resolveTier('unknowntier', tmpRoot), (err) => {
590
+ assert.match(err.message, /monorepo sibling fallback/);
591
+ assert.match(err.message, /Drop --tier unknowntier/);
592
+ assert.match(err.message, /rune\.dev\/docs\/hooks/);
593
+ return true;
594
+ });
595
+ });
596
+ });
597
+
497
598
  describe('regression: Pro+Claude parity', () => {
498
599
  test('Pro entries present BEFORE and AFTER migration produce equivalent settings.json shape', async () => {
499
600
  await mkdir(path.join(tmpRoot, '.claude'), { recursive: true });
@@ -3,12 +3,14 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promis
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { afterEach, beforeEach, describe, test } from 'node:test';
6
+ import { fileURLToPath } from 'node:url';
6
7
  import * as windsurf from '../adapters/hooks/windsurf.js';
7
8
  import { installHooks } from '../commands/hooks/install.js';
8
9
  import { hookStatus } from '../commands/hooks/status.js';
9
10
  import { uninstallHooks } from '../commands/hooks/uninstall.js';
10
11
 
11
- const RUNE_ROOT = path.resolve(import.meta.dirname, '..', '..');
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const RUNE_ROOT = path.resolve(__dirname, '..', '..');
12
14
  const WF_DIR = '.windsurf/workflows';
13
15
  const RULES_DIR = '.windsurf/rules';
14
16
 
@@ -16,9 +16,10 @@
16
16
  * third-party) plugs in by shipping a manifest at a known path.
17
17
  */
18
18
 
19
- import { existsSync } from 'node:fs';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
20
  import { readFile } from 'node:fs/promises';
21
21
  import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
22
23
 
23
24
  /** Known tier env vars. Adding a new tier = add its env var here. */
24
25
  export const TIER_ENV_VARS = Object.freeze({
@@ -26,6 +27,56 @@ export const TIER_ENV_VARS = Object.freeze({
26
27
  business: 'RUNE_BUSINESS_ROOT',
27
28
  });
28
29
 
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+
32
+ /**
33
+ * Read Free compiler version from the bundled package.json. Cached after first read.
34
+ * @returns {string} semver string, e.g. "2.12.1"
35
+ */
36
+ let _freeVersionCache = null;
37
+ export function getFreeVersion() {
38
+ if (_freeVersionCache) return _freeVersionCache;
39
+ const pkgPath = path.resolve(__dirname, '..', '..', '..', 'package.json');
40
+ try {
41
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
42
+ _freeVersionCache = typeof pkg.version === 'string' ? pkg.version : '0.0.0';
43
+ } catch {
44
+ _freeVersionCache = '0.0.0';
45
+ }
46
+ return _freeVersionCache;
47
+ }
48
+
49
+ /** For test/override scenarios — resets the cache. */
50
+ export function _resetFreeVersionCache() {
51
+ _freeVersionCache = null;
52
+ }
53
+
54
+ /**
55
+ * Parse a semver `x.y.z` (ignores pre-release/build) into a tuple. Returns null on malformed input.
56
+ * @param {string} v
57
+ * @returns {[number,number,number]|null}
58
+ */
59
+ export function parseSemver(v) {
60
+ if (typeof v !== 'string') return null;
61
+ const m = v.match(/^(\d+)\.(\d+)\.(\d+)/);
62
+ if (!m) return null;
63
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
64
+ }
65
+
66
+ /**
67
+ * Compare two semver strings. Returns -1, 0, 1 or null if either is malformed.
68
+ */
69
+ export function compareSemver(a, b) {
70
+ const pa = parseSemver(a);
71
+ const pb = parseSemver(b);
72
+ if (!pa || !pb) return null;
73
+ for (let i = 0; i < 3; i++) {
74
+ if (pa[i] < pb[i]) return -1;
75
+ if (pa[i] > pb[i]) return 1;
76
+ }
77
+ return 0;
78
+ }
79
+
29
80
  /** Valid event names a manifest entry may declare. */
30
81
  const VALID_EVENTS = new Set(['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'statusLine']);
31
82
 
@@ -148,11 +199,23 @@ export function validateManifest(manifest, source = '<memory>') {
148
199
  };
149
200
  });
150
201
 
202
+ const minFreeVersion =
203
+ typeof manifest.minFreeVersion === 'string' && manifest.minFreeVersion.length > 0 ? manifest.minFreeVersion : null;
204
+ if (manifest.minFreeVersion !== undefined && minFreeVersion === null) {
205
+ throw new Error(`Manifest ${source}: 'minFreeVersion' must be a non-empty string if present`);
206
+ }
207
+ if (minFreeVersion && !parseSemver(minFreeVersion)) {
208
+ throw new Error(
209
+ `Manifest ${source}: 'minFreeVersion' must be semver x.y.z (got ${JSON.stringify(minFreeVersion)})`,
210
+ );
211
+ }
212
+
151
213
  return {
152
214
  name: typeof manifest.name === 'string' ? manifest.name : `Rune ${manifest.tier} Hooks`,
153
215
  description: typeof manifest.description === 'string' ? manifest.description : '',
154
216
  tier: manifest.tier,
155
217
  version: typeof manifest.version === 'string' ? manifest.version : '0.0.0',
218
+ minFreeVersion,
156
219
  requires: Array.isArray(manifest.requires) ? [...manifest.requires] : [],
157
220
  entries,
158
221
  overrides: manifest.overrides && typeof manifest.overrides === 'object' ? { ...manifest.overrides } : {},
@@ -160,6 +223,29 @@ export function validateManifest(manifest, source = '<memory>') {
160
223
  };
161
224
  }
162
225
 
226
+ /**
227
+ * Assert the current Free compiler satisfies a manifest's `minFreeVersion`.
228
+ * Throws with an actionable upgrade message when the local Free is too old.
229
+ *
230
+ * @param {import('./tiers.js').TierManifest} manifest
231
+ * @param {string} [currentFreeVersion] — defaults to `getFreeVersion()`. Override for tests.
232
+ */
233
+ export function assertFreeVersionCompat(manifest, currentFreeVersion) {
234
+ if (!manifest || !manifest.minFreeVersion) return;
235
+ const current = currentFreeVersion ?? getFreeVersion();
236
+ const cmp = compareSemver(current, manifest.minFreeVersion);
237
+ if (cmp === null) {
238
+ // Malformed input — surface but don't block (defensive).
239
+ return;
240
+ }
241
+ if (cmp < 0) {
242
+ throw new Error(
243
+ `Tier '${manifest.tier}' requires Rune Free >= ${manifest.minFreeVersion} but the installed compiler is ${current}. ` +
244
+ `Upgrade Free first: \`npm i -g @rune-kit/rune@latest\` (or \`npx @rune-kit/rune@latest hooks install --tier ${manifest.tier}\`).`,
245
+ );
246
+ }
247
+ }
248
+
163
249
  /**
164
250
  * Check whether a manifest's `requires` list is satisfied by current env.
165
251
  * Returns { ok, missing }.
@@ -180,16 +266,32 @@ export function checkManifestRequires(manifest) {
180
266
  export async function resolveTier(tier, projectRoot) {
181
267
  const manifestPath = locateTierManifest(tier, projectRoot);
182
268
  if (!manifestPath) {
183
- const envHint = TIER_ENV_VARS[tier] ? ` Set $${TIER_ENV_VARS[tier]} to your ${tier} install dir.` : '';
184
- throw new Error(
185
- `Could not locate '${tier}' tier manifest.${envHint}` +
186
- ` See https://rune.dev/docs/hooks#tiers for install instructions.`,
187
- );
269
+ const envVar = TIER_ENV_VARS[tier];
270
+ const capitalized = tier.charAt(0).toUpperCase() + tier.slice(1);
271
+ const siblingPath = path.resolve(projectRoot, '..', capitalized, 'hooks', 'manifest.json');
272
+ const lines = [`Could not locate '${tier}' tier manifest. Rune looked in:`];
273
+ if (envVar) {
274
+ lines.push(` 1. $${envVar}/hooks/manifest.json (env var — set this if ${capitalized} is installed elsewhere)`);
275
+ lines.push(` 2. ${siblingPath} (monorepo sibling fallback)`);
276
+ } else {
277
+ lines.push(` • ${siblingPath} (monorepo sibling fallback)`);
278
+ }
279
+ lines.push('');
280
+ lines.push(`Fix one of:`);
281
+ if (envVar) {
282
+ lines.push(` • export ${envVar}=/path/to/${capitalized} # point at your ${tier} install`);
283
+ }
284
+ lines.push(` • Clone ${capitalized} next to Free so the sibling path resolves`);
285
+ lines.push(` • Drop --tier ${tier} to install Free-only hooks`);
286
+ lines.push('');
287
+ lines.push(`See https://rune.dev/docs/hooks#tiers for details.`);
288
+ throw new Error(lines.join('\n'));
188
289
  }
189
290
  const manifest = await loadTierManifest(manifestPath);
190
291
  if (manifest.tier !== tier) {
191
292
  throw new Error(`Manifest at ${manifestPath} declares tier='${manifest.tier}' but was requested as '${tier}'`);
192
293
  }
294
+ assertFreeVersionCompat(manifest);
193
295
  return manifest;
194
296
  }
195
297
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rune-kit/rune",
3
- "version": "2.12.0",
3
+ "version": "2.12.2",
4
4
  "description": "62-skill mesh for AI coding assistants — runtime auto-discipline via native hooks (Claude/Cursor/Windsurf/Antigravity), 5-layer architecture, 215+ connections, multi-platform compiler.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,12 +19,14 @@
19
19
  import { existsSync } from 'node:fs';
20
20
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
21
21
  import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
22
23
  import { parseArgs } from 'node:util';
23
24
  import { detectInvariants, renderInvariants } from './detect-invariants.js';
24
25
  import { applyInvariantsPointer } from './inject-claude-md.js';
25
26
 
26
27
  const AUTO_HEADER = '## Auto-detected (new)';
27
28
  const TEMPLATE_REL_PATH = '../references/invariants-template.md';
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
30
 
29
31
  /**
30
32
  * Locate the next `## `-level section boundary in `text`, ignoring any `##`
@@ -60,7 +62,7 @@ function findNextSectionOffset(text) {
60
62
  }
61
63
 
62
64
  export async function loadTemplate(templatePath) {
63
- const resolved = templatePath ?? path.resolve(import.meta.dirname, TEMPLATE_REL_PATH);
65
+ const resolved = templatePath ?? path.resolve(__dirname, TEMPLATE_REL_PATH);
64
66
  return readFile(resolved, 'utf8');
65
67
  }
66
68