@rune-kit/rune 2.28.0 → 2.29.1
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/.codex-plugin/plugin.json +14 -0
- package/README.md +114 -41
- package/compiler/__tests__/adapter-model-mapping.test.js +22 -24
- package/compiler/__tests__/adapters.test.js +4 -1
- package/compiler/__tests__/doctor-mesh.test.js +55 -1
- package/compiler/__tests__/governance-collector.test.js +1 -0
- package/compiler/__tests__/hooks-codex.test.js +85 -0
- package/compiler/__tests__/hooks-doctor-tier.test.js +3 -5
- package/compiler/__tests__/hooks-drift.test.js +2 -2
- package/compiler/__tests__/hooks-install.test.js +2 -2
- package/compiler/__tests__/hooks-tiers.test.js +3 -4
- package/compiler/__tests__/setup.test.js +59 -0
- package/compiler/__tests__/status.test.js +7 -1
- package/compiler/__tests__/tier-override.test.js +42 -1
- package/compiler/__tests__/transforms.test.js +15 -0
- package/compiler/__tests__/update.test.js +416 -0
- package/compiler/adapters/codex.js +88 -11
- package/compiler/adapters/hooks/codex.js +178 -0
- package/compiler/adapters/hooks/index.js +10 -0
- package/compiler/adapters/openclaw.js +2 -2
- package/compiler/bin/rune.js +24 -4
- package/compiler/commands/hooks/install.js +2 -2
- package/compiler/commands/hooks/status.js +1 -1
- package/compiler/commands/setup.js +101 -28
- package/compiler/commands/update.js +354 -0
- package/compiler/doctor.js +12 -6
- package/compiler/emitter.js +46 -3
- package/compiler/governance-collector.js +3 -2
- package/compiler/status.js +4 -7
- package/compiler/transforms/branding.js +2 -2
- package/compiler/transforms/subagents.js +3 -3
- package/hooks/codex-hooks.json +96 -0
- package/hooks/context-watch/index.cjs +8 -5
- package/hooks/intent-router/index.cjs +3 -0
- package/hooks/lib/hook-output.cjs +11 -3
- package/hooks/post-session-reflect/index.cjs +65 -24
- package/hooks/pre-compact/index.cjs +10 -4
- package/hooks/pre-tool-guard/index.cjs +38 -36
- package/hooks/run-hook +1 -1
- package/hooks/run-hook.cmd +1 -1
- package/hooks/secrets-scan/index.cjs +18 -2
- package/package.json +3 -2
- package/skills/browser-pilot/SKILL.md +1 -0
- package/skills/completion-gate/SKILL.md +5 -5
- package/skills/doc-processor/SKILL.md +2 -2
- package/skills/hallucination-guard/SKILL.md +1 -0
- package/skills/journal/SKILL.md +1 -0
- package/skills/retro/SKILL.md +2 -2
- package/skills/session-bridge/SKILL.md +1 -0
- package/skills/session-bridge/scripts/load-invariants.js +1 -1
- package/skills/video-creator/SKILL.md +1 -1
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import assert from 'node:assert';
|
|
16
|
-
import { existsSync } from 'node:fs';
|
|
17
16
|
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
18
17
|
import { tmpdir } from 'node:os';
|
|
19
18
|
import path from 'node:path';
|
|
@@ -54,7 +53,7 @@ const PRO_MANIFEST_FIXTURE = {
|
|
|
54
53
|
skill: 'context-inject',
|
|
55
54
|
event: 'UserPromptSubmit',
|
|
56
55
|
matcher: '.*',
|
|
57
|
-
command:
|
|
56
|
+
command: `node "\${RUNE_PRO_ROOT}/hooks/run-hook.cjs" context-inject`,
|
|
58
57
|
description: 'Inject rolling context summary',
|
|
59
58
|
platforms: { claude: 'hook', cursor: 'rule-alwaysApply', windsurf: 'workflow+rule', antigravity: 'rule' },
|
|
60
59
|
},
|
|
@@ -63,7 +62,7 @@ const PRO_MANIFEST_FIXTURE = {
|
|
|
63
62
|
skill: 'context-sense',
|
|
64
63
|
event: 'PreToolUse',
|
|
65
64
|
matcher: 'Edit|Write',
|
|
66
|
-
command:
|
|
65
|
+
command: `node "\${RUNE_PRO_ROOT}/hooks/run-hook.cjs" context-sense`,
|
|
67
66
|
description: 'Detect context pressure',
|
|
68
67
|
globs: ['**/*.ts', '**/*.js'],
|
|
69
68
|
platforms: { claude: 'hook', cursor: 'rule-glob', windsurf: 'workflow+rule', antigravity: 'rule' },
|
|
@@ -72,7 +71,7 @@ const PRO_MANIFEST_FIXTURE = {
|
|
|
72
71
|
id: 'rune-pulse',
|
|
73
72
|
skill: 'rune-pulse',
|
|
74
73
|
event: 'statusLine',
|
|
75
|
-
command:
|
|
74
|
+
command: `node "\${RUNE_PRO_ROOT}/hooks/rune-pulse/index.cjs"`,
|
|
76
75
|
padding: 0,
|
|
77
76
|
claudeOnly: true,
|
|
78
77
|
description: 'Context pressure indicator',
|
|
@@ -397,6 +397,65 @@ describe('runSetup — Pro skill installation (regression: rune:autopilot Unknow
|
|
|
397
397
|
|
|
398
398
|
assert.deepStrictEqual(result.skillResults, []);
|
|
399
399
|
});
|
|
400
|
+
|
|
401
|
+
test('installs Codex tier skills into project .agents/skills', async () => {
|
|
402
|
+
const projectRoot = path.join(tmpRoot, 'codex-project');
|
|
403
|
+
await mkdir(path.join(projectRoot, '.codex'), { recursive: true });
|
|
404
|
+
await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
|
|
405
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
406
|
+
|
|
407
|
+
const result = await runSetup({
|
|
408
|
+
projectRoot,
|
|
409
|
+
runeRoot,
|
|
410
|
+
args: { here: true, platform: 'codex', preset: 'gentle', tier: 'pro' },
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
assert.strictEqual(result.skillTarget.source, 'codex-project');
|
|
414
|
+
assert.deepStrictEqual(result.platforms, ['codex']);
|
|
415
|
+
assert.ok(existsSync(path.join(projectRoot, '.codex', 'hooks.json')));
|
|
416
|
+
const installed = path.join(projectRoot, '.agents', 'skills', 'rune-autopilot', 'SKILL.md');
|
|
417
|
+
assert.ok(existsSync(installed));
|
|
418
|
+
const content = readFileSync(installed, 'utf-8');
|
|
419
|
+
assert.match(content, /^name: rune-autopilot$/m);
|
|
420
|
+
assert.doesNotMatch(content, /^context: fork$/m);
|
|
421
|
+
assert.doesNotMatch(content, /^agent: general-purpose$/m);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test('installs tier skills into every detected non-Claude platform using native adapters', async () => {
|
|
425
|
+
const projectRoot = path.join(tmpRoot, 'multi-project');
|
|
426
|
+
await mkdir(path.join(projectRoot, '.cursor'), { recursive: true });
|
|
427
|
+
await mkdir(path.join(projectRoot, '.windsurf'), { recursive: true });
|
|
428
|
+
await seedTier(projectRoot, 'business', { skills: ['quarterly-review'] });
|
|
429
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
430
|
+
|
|
431
|
+
const result = await runSetup({
|
|
432
|
+
projectRoot,
|
|
433
|
+
runeRoot,
|
|
434
|
+
args: { here: true, platform: 'all', preset: 'gentle', tier: 'business' },
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
assert.deepStrictEqual(result.platforms.sort(), ['cursor', 'windsurf']);
|
|
438
|
+
assert.ok(existsSync(path.join(projectRoot, '.cursor', 'skills', 'rune-quarterly-review', 'SKILL.md')));
|
|
439
|
+
assert.ok(existsSync(path.join(projectRoot, '.windsurf', 'skills', 'rune-quarterly-review', 'SKILL.md')));
|
|
440
|
+
assert.deepStrictEqual(result.skillResults.map((entry) => entry.platform).sort(), ['cursor', 'windsurf']);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('Codex dry-run reports tier skills on a fresh project without creating directories', async () => {
|
|
444
|
+
const projectRoot = path.join(tmpRoot, 'fresh-codex-project');
|
|
445
|
+
await mkdir(projectRoot, { recursive: true });
|
|
446
|
+
await seedTier(projectRoot, 'business', { skills: ['launch-product'] });
|
|
447
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
448
|
+
|
|
449
|
+
const result = await runSetup({
|
|
450
|
+
projectRoot,
|
|
451
|
+
runeRoot,
|
|
452
|
+
args: { here: true, platform: 'codex', preset: 'gentle', tier: 'business', dry: true },
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
assert.deepStrictEqual(result.skillResults[0].installed, ['launch-product']);
|
|
456
|
+
assert.strictEqual(result.skillResults[0].reason, null);
|
|
457
|
+
assert.ok(!existsSync(path.join(projectRoot, '.agents')));
|
|
458
|
+
});
|
|
400
459
|
});
|
|
401
460
|
|
|
402
461
|
describe('formatSetupResult', () => {
|
|
@@ -2,6 +2,7 @@ import assert from 'node:assert';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { describe, test } from 'node:test';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { checkMeshIntegrity } from '../doctor.js';
|
|
5
6
|
import { collectStats, renderStatus, renderStatusJson } from '../status.js';
|
|
6
7
|
|
|
7
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -42,6 +43,11 @@ describe('collectStats', () => {
|
|
|
42
43
|
assert.ok(parseFloat(stats.avgConnections) > 1);
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
test('uses the same canonical outbound connection count as doctor', async () => {
|
|
47
|
+
const [stats, mesh] = await Promise.all([collectStats(RUNE_ROOT), checkMeshIntegrity(RUNE_ROOT)]);
|
|
48
|
+
assert.strictEqual(stats.totalConnections, mesh.stats.connections);
|
|
49
|
+
});
|
|
50
|
+
|
|
45
51
|
test('discovers free packs', async () => {
|
|
46
52
|
const stats = await collectStats(RUNE_ROOT);
|
|
47
53
|
assert.ok(stats.freePacks.length >= 14);
|
|
@@ -203,7 +209,7 @@ describe('renderStatus', () => {
|
|
|
203
209
|
if (stats.bizPacks.length > 0) {
|
|
204
210
|
const output = renderStatus(stats);
|
|
205
211
|
assert.ok(output.includes('Business Packs'));
|
|
206
|
-
assert.ok(output.includes('@rune-
|
|
212
|
+
assert.ok(output.includes('@rune-pro/'));
|
|
207
213
|
}
|
|
208
214
|
});
|
|
209
215
|
|
|
@@ -11,7 +11,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
11
11
|
import { tmpdir } from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { describe, test } from 'node:test';
|
|
14
|
-
import { discoverTieredPacks } from '../emitter.js';
|
|
14
|
+
import { discoverTieredPacks, discoverTieredSkills } from '../emitter.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Create a temp directory structure with PACK.md files for testing
|
|
@@ -197,3 +197,44 @@ describe('discoverTieredPacks', () => {
|
|
|
197
197
|
}
|
|
198
198
|
});
|
|
199
199
|
});
|
|
200
|
+
|
|
201
|
+
describe('discoverTieredSkills', () => {
|
|
202
|
+
test('includes standalone Pro and Business skills and applies tier priority', async () => {
|
|
203
|
+
const root = mkdtempSync(path.join(tmpdir(), 'rune-tier-skills-'));
|
|
204
|
+
try {
|
|
205
|
+
const freeSkills = path.join(root, 'Free', 'skills');
|
|
206
|
+
const proExtensions = path.join(root, 'Pro', 'extensions');
|
|
207
|
+
const businessExtensions = path.join(root, 'Business', 'extensions');
|
|
208
|
+
for (const [skillsDir, names] of [
|
|
209
|
+
[freeSkills, ['cook', 'shared']],
|
|
210
|
+
[path.join(root, 'Pro', 'skills'), ['autopilot', 'shared']],
|
|
211
|
+
[path.join(root, 'Business', 'skills'), ['launch-product', 'shared']],
|
|
212
|
+
]) {
|
|
213
|
+
for (const name of names) {
|
|
214
|
+
const dir = path.join(skillsDir, name);
|
|
215
|
+
mkdirSync(dir, { recursive: true });
|
|
216
|
+
writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${name}\n---\n# ${name}\n`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
mkdirSync(proExtensions, { recursive: true });
|
|
220
|
+
mkdirSync(businessExtensions, { recursive: true });
|
|
221
|
+
|
|
222
|
+
const skills = await discoverTieredSkills(freeSkills, {
|
|
223
|
+
pro: proExtensions,
|
|
224
|
+
business: businessExtensions,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
assert.deepStrictEqual(
|
|
228
|
+
skills.map(({ name, tier }) => [name, tier]),
|
|
229
|
+
[
|
|
230
|
+
['autopilot', 'pro'],
|
|
231
|
+
['cook', 'free'],
|
|
232
|
+
['launch-product', 'business'],
|
|
233
|
+
['shared', 'business'],
|
|
234
|
+
],
|
|
235
|
+
);
|
|
236
|
+
} finally {
|
|
237
|
+
rmSync(root, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -2,6 +2,7 @@ import assert from 'node:assert';
|
|
|
2
2
|
import { describe, test } from 'node:test';
|
|
3
3
|
import { addBranding } from '../transforms/branding.js';
|
|
4
4
|
import { transformCrossReferences } from '../transforms/cross-references.js';
|
|
5
|
+
import { transformSubagents } from '../transforms/subagents.js';
|
|
5
6
|
import { transformToolNames } from '../transforms/tool-names.js';
|
|
6
7
|
|
|
7
8
|
// --- Mock adapter ---
|
|
@@ -75,6 +76,20 @@ describe('transformToolNames', () => {
|
|
|
75
76
|
});
|
|
76
77
|
});
|
|
77
78
|
|
|
79
|
+
describe('transformSubagents', () => {
|
|
80
|
+
const input = 'Launch 3 parallel agents as independent Task agents.';
|
|
81
|
+
|
|
82
|
+
test('preserves native Codex subagent instructions', () => {
|
|
83
|
+
assert.strictEqual(transformSubagents(input, { name: 'codex' }), input);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('keeps sequential fallback for platforms without native subagents', () => {
|
|
87
|
+
const result = transformSubagents(input, { name: 'generic' });
|
|
88
|
+
assert.notStrictEqual(result, input);
|
|
89
|
+
assert.match(result, /sequentially/);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
78
93
|
// --- Branding transform ---
|
|
79
94
|
|
|
80
95
|
describe('addBranding', () => {
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { afterEach, beforeEach, describe, test } from 'node:test';
|
|
6
|
+
import { installHooks } from '../commands/hooks/install.js';
|
|
7
|
+
import { detectTiers } from '../commands/setup.js';
|
|
8
|
+
import {
|
|
9
|
+
detectInstalledPreset,
|
|
10
|
+
detectInstalledTiers,
|
|
11
|
+
formatUpdateResult,
|
|
12
|
+
pullTierRepos,
|
|
13
|
+
runUpdate,
|
|
14
|
+
} from '../commands/update.js';
|
|
15
|
+
|
|
16
|
+
let tmpRoot;
|
|
17
|
+
|
|
18
|
+
/** Seed a tier repo sibling (../Pro or ../Business) with a manifest whose
|
|
19
|
+
* entries reference the tier env var — matching what real tier manifests emit. */
|
|
20
|
+
async function seedTierRepo(projectRoot, tier, opts = {}) {
|
|
21
|
+
const tierRoot = path.join(projectRoot, '..', tier === 'pro' ? 'Pro' : 'Business');
|
|
22
|
+
await mkdir(path.join(tierRoot, 'hooks'), { recursive: true });
|
|
23
|
+
const envVar = tier === 'pro' ? 'RUNE_PRO_ROOT' : 'RUNE_BUSINESS_ROOT';
|
|
24
|
+
await writeFile(
|
|
25
|
+
path.join(tierRoot, 'hooks', 'manifest.json'),
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
tier,
|
|
28
|
+
version: '1.0.0',
|
|
29
|
+
minFreeVersion: '2.0.0',
|
|
30
|
+
requires: [envVar],
|
|
31
|
+
entries: [
|
|
32
|
+
{
|
|
33
|
+
id: `${tier}-pulse`,
|
|
34
|
+
event: 'PreToolUse',
|
|
35
|
+
matcher: 'Edit|Write',
|
|
36
|
+
command: `node "\${${envVar}}/hooks/pulse.js"`,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
if (opts.git) {
|
|
42
|
+
await mkdir(path.join(tierRoot, '.git'), { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
return tierRoot;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function seedFakeRuneRoot(root) {
|
|
48
|
+
const runeRoot = path.join(root, 'fake-rune');
|
|
49
|
+
await mkdir(path.join(runeRoot, 'skills'), { recursive: true });
|
|
50
|
+
return runeRoot;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Install real Free hooks (optionally + tier) into the tmp project so the
|
|
54
|
+
* "already installed" detection paths have something real to read. */
|
|
55
|
+
async function installProjectHooks(projectRoot, { preset = 'gentle', tier } = {}) {
|
|
56
|
+
await mkdir(path.join(projectRoot, '.claude'), { recursive: true });
|
|
57
|
+
await installHooks(projectRoot, { preset, tier, platform: 'claude' });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(async () => {
|
|
61
|
+
delete process.env.RUNE_PRO_ROOT;
|
|
62
|
+
delete process.env.RUNE_BUSINESS_ROOT;
|
|
63
|
+
tmpRoot = await mkdtemp(path.join(tmpdir(), 'rune-update-'));
|
|
64
|
+
await mkdir(path.join(tmpRoot, 'project'), { recursive: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(async () => {
|
|
68
|
+
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
|
69
|
+
delete process.env.RUNE_PRO_ROOT;
|
|
70
|
+
delete process.env.RUNE_BUSINESS_ROOT;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('detectInstalledTiers', () => {
|
|
74
|
+
test('returns [] for a project with no Rune hook configs', async () => {
|
|
75
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
76
|
+
assert.deepStrictEqual(await detectInstalledTiers(projectRoot), []);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('returns [] when only Free preset hooks are installed (no false positive)', async () => {
|
|
80
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
81
|
+
await installProjectHooks(projectRoot, { preset: 'gentle' });
|
|
82
|
+
assert.deepStrictEqual(await detectInstalledTiers(projectRoot), []);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('detects pro from RUNE_PRO_ROOT tokens in .claude/settings.json', async () => {
|
|
86
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
87
|
+
await seedTierRepo(projectRoot, 'pro');
|
|
88
|
+
await installProjectHooks(projectRoot, { preset: 'gentle', tier: ['pro'] });
|
|
89
|
+
assert.deepStrictEqual(await detectInstalledTiers(projectRoot), ['pro']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('detects business from tier token in .codex/hooks.json', async () => {
|
|
93
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
94
|
+
await mkdir(path.join(projectRoot, '.codex'), { recursive: true });
|
|
95
|
+
await writeFile(
|
|
96
|
+
path.join(projectRoot, '.codex', 'hooks.json'),
|
|
97
|
+
JSON.stringify({
|
|
98
|
+
hooks: {
|
|
99
|
+
PreToolUse: [
|
|
100
|
+
{
|
|
101
|
+
matcher: '.*',
|
|
102
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal env token by design
|
|
103
|
+
hooks: [{ type: 'command', command: 'node "${RUNE_BUSINESS_ROOT}/hooks/gate.js"' }],
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
assert.deepStrictEqual(await detectInstalledTiers(projectRoot), ['business']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('detects pro from rune-tier frontmatter in .cursor/rules', async () => {
|
|
113
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
114
|
+
const rulesDir = path.join(projectRoot, '.cursor', 'rules');
|
|
115
|
+
await mkdir(rulesDir, { recursive: true });
|
|
116
|
+
await writeFile(path.join(rulesDir, 'rune-pro-pulse.mdc'), '---\nrune-managed: true\nrune-tier: pro\n---\n# x\n');
|
|
117
|
+
assert.deepStrictEqual(await detectInstalledTiers(projectRoot), ['pro']);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('detectInstalledPreset', () => {
|
|
122
|
+
test('returns null when nothing is installed', async () => {
|
|
123
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
124
|
+
assert.strictEqual(await detectInstalledPreset(projectRoot), null);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('returns the installed preset (strict)', async () => {
|
|
128
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
129
|
+
await installProjectHooks(projectRoot, { preset: 'strict' });
|
|
130
|
+
assert.strictEqual(await detectInstalledPreset(projectRoot), 'strict');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('returns gentle for a gentle install', async () => {
|
|
134
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
135
|
+
await installProjectHooks(projectRoot, { preset: 'gentle' });
|
|
136
|
+
assert.strictEqual(await detectInstalledPreset(projectRoot), 'gentle');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('pullTierRepos', () => {
|
|
141
|
+
test('reports absent tiers as skipped without calling git', async () => {
|
|
142
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
143
|
+
const calls = [];
|
|
144
|
+
const exec = async (...args) => {
|
|
145
|
+
calls.push(args);
|
|
146
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
147
|
+
};
|
|
148
|
+
const detected = detectTiers(projectRoot, { wellKnownPaths: { pro: [], business: [] } });
|
|
149
|
+
const result = await pullTierRepos({ detected, exec });
|
|
150
|
+
assert.strictEqual(result.ok, true);
|
|
151
|
+
assert.strictEqual(calls.length, 0);
|
|
152
|
+
for (const r of result.results) {
|
|
153
|
+
assert.strictEqual(r.status, 'absent');
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('skips (with note) a detected tier that is not a git repo', async () => {
|
|
158
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
159
|
+
await seedTierRepo(projectRoot, 'pro'); // no .git
|
|
160
|
+
const exec = async () => {
|
|
161
|
+
throw new Error('git must not be called');
|
|
162
|
+
};
|
|
163
|
+
const detected = detectTiers(projectRoot, { wellKnownPaths: { pro: [], business: [] } });
|
|
164
|
+
const result = await pullTierRepos({ detected, exec });
|
|
165
|
+
assert.strictEqual(result.ok, true);
|
|
166
|
+
const pro = result.results.find((r) => r.tier === 'pro');
|
|
167
|
+
assert.strictEqual(pro.status, 'skipped');
|
|
168
|
+
assert.match(pro.detail, /not a git repo/i);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('pulls a detected git tier with git -C <root> pull --ff-only', async () => {
|
|
172
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
173
|
+
const tierRoot = await seedTierRepo(projectRoot, 'pro', { git: true });
|
|
174
|
+
const calls = [];
|
|
175
|
+
const exec = async (cmd, argv) => {
|
|
176
|
+
calls.push([cmd, argv]);
|
|
177
|
+
return { code: 0, stdout: 'Already up to date.\n', stderr: '' };
|
|
178
|
+
};
|
|
179
|
+
const detected = detectTiers(projectRoot, { wellKnownPaths: { pro: [], business: [] } });
|
|
180
|
+
const result = await pullTierRepos({ detected, exec });
|
|
181
|
+
assert.strictEqual(result.ok, true);
|
|
182
|
+
const pro = result.results.find((r) => r.tier === 'pro');
|
|
183
|
+
assert.strictEqual(pro.status, 'pulled');
|
|
184
|
+
assert.strictEqual(calls.length, 1);
|
|
185
|
+
assert.strictEqual(calls[0][0], 'git');
|
|
186
|
+
assert.deepStrictEqual(calls[0][1], ['-C', path.resolve(tierRoot), 'pull', '--ff-only']);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('fails loud when git pull fails (dirty tree, auth, ...)', async () => {
|
|
190
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
191
|
+
await seedTierRepo(projectRoot, 'pro', { git: true });
|
|
192
|
+
const exec = async () => ({ code: 1, stdout: '', stderr: 'error: Your local changes would be overwritten' });
|
|
193
|
+
const detected = detectTiers(projectRoot, { wellKnownPaths: { pro: [], business: [] } });
|
|
194
|
+
const result = await pullTierRepos({ detected, exec });
|
|
195
|
+
assert.strictEqual(result.ok, false);
|
|
196
|
+
const pro = result.results.find((r) => r.tier === 'pro');
|
|
197
|
+
assert.strictEqual(pro.status, 'failed');
|
|
198
|
+
assert.match(pro.detail, /local changes/);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('runUpdate', () => {
|
|
203
|
+
test('fails with guidance when no Rune installation is detected', async () => {
|
|
204
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
205
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
206
|
+
const result = await runUpdate({
|
|
207
|
+
projectRoot,
|
|
208
|
+
runeRoot,
|
|
209
|
+
args: {},
|
|
210
|
+
deps: { wellKnownPaths: { pro: [], business: [] } },
|
|
211
|
+
});
|
|
212
|
+
assert.strictEqual(result.ok, false);
|
|
213
|
+
assert.match(result.reason, /setup/i);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('re-runs setup reusing installed preset + tier, pulls tier repo', async () => {
|
|
217
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
218
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
219
|
+
await seedTierRepo(projectRoot, 'pro', { git: true });
|
|
220
|
+
await installProjectHooks(projectRoot, { preset: 'strict', tier: ['pro'] });
|
|
221
|
+
const execCalls = [];
|
|
222
|
+
const exec = async (cmd, argv) => {
|
|
223
|
+
execCalls.push([cmd, argv]);
|
|
224
|
+
return { code: 0, stdout: 'Already up to date.\n', stderr: '' };
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const result = await runUpdate({
|
|
228
|
+
projectRoot,
|
|
229
|
+
runeRoot,
|
|
230
|
+
args: {},
|
|
231
|
+
deps: { exec, skillTarget: runeRoot, wellKnownPaths: { pro: [], business: [] } },
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
assert.strictEqual(result.ok, true);
|
|
235
|
+
assert.strictEqual(result.preset, 'strict');
|
|
236
|
+
assert.deepStrictEqual(result.tiers, ['pro']);
|
|
237
|
+
assert.strictEqual(execCalls.length, 1);
|
|
238
|
+
assert.ok(result.setup, 'setup result expected');
|
|
239
|
+
assert.deepStrictEqual(result.setup.tiers, ['pro']);
|
|
240
|
+
assert.strictEqual(result.setup.preset, 'strict');
|
|
241
|
+
assert.ok(result.drift, 'drift result expected');
|
|
242
|
+
const pro = result.pull.results.find((r) => r.tier === 'pro');
|
|
243
|
+
assert.strictEqual(pro.status, 'pulled');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('does NOT run setup when a tier pull fails', async () => {
|
|
247
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
248
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
249
|
+
await seedTierRepo(projectRoot, 'pro', { git: true });
|
|
250
|
+
await installProjectHooks(projectRoot, { preset: 'gentle', tier: ['pro'] });
|
|
251
|
+
let setupRan = false;
|
|
252
|
+
const result = await runUpdate({
|
|
253
|
+
projectRoot,
|
|
254
|
+
runeRoot,
|
|
255
|
+
args: {},
|
|
256
|
+
deps: {
|
|
257
|
+
exec: async () => ({ code: 128, stdout: '', stderr: 'fatal: could not read Username' }),
|
|
258
|
+
runSetupFn: async () => {
|
|
259
|
+
setupRan = true;
|
|
260
|
+
return {};
|
|
261
|
+
},
|
|
262
|
+
wellKnownPaths: { pro: [], business: [] },
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
assert.strictEqual(result.ok, false);
|
|
266
|
+
assert.strictEqual(setupRan, false);
|
|
267
|
+
assert.match(result.reason, /pull failed/i);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('--no-pull skips git pulls entirely', async () => {
|
|
271
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
272
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
273
|
+
await seedTierRepo(projectRoot, 'pro', { git: true });
|
|
274
|
+
await installProjectHooks(projectRoot, { preset: 'gentle', tier: ['pro'] });
|
|
275
|
+
const exec = async () => {
|
|
276
|
+
throw new Error('git must not be called with --no-pull');
|
|
277
|
+
};
|
|
278
|
+
const result = await runUpdate({
|
|
279
|
+
projectRoot,
|
|
280
|
+
runeRoot,
|
|
281
|
+
args: { 'no-pull': true },
|
|
282
|
+
deps: { exec, skillTarget: runeRoot, wellKnownPaths: { pro: [], business: [] } },
|
|
283
|
+
});
|
|
284
|
+
assert.strictEqual(result.ok, true);
|
|
285
|
+
for (const r of result.pull.results) {
|
|
286
|
+
assert.notStrictEqual(r.status, 'pulled');
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('skips (with note) an installed tier whose repo is no longer found', async () => {
|
|
291
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
292
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
293
|
+
// Install hooks WITH the pro tier while the repo exists...
|
|
294
|
+
await seedTierRepo(projectRoot, 'pro');
|
|
295
|
+
await installProjectHooks(projectRoot, { preset: 'gentle', tier: ['pro'] });
|
|
296
|
+
// ...then the repo disappears (user deleted the clone).
|
|
297
|
+
await rm(path.join(projectRoot, '..', 'Pro'), { recursive: true, force: true });
|
|
298
|
+
|
|
299
|
+
const result = await runUpdate({
|
|
300
|
+
projectRoot,
|
|
301
|
+
runeRoot,
|
|
302
|
+
args: {},
|
|
303
|
+
deps: { skillTarget: runeRoot, wellKnownPaths: { pro: [], business: [] } },
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
assert.strictEqual(result.ok, true);
|
|
307
|
+
assert.deepStrictEqual(result.tiers, []);
|
|
308
|
+
assert.ok(
|
|
309
|
+
result.notes.some((n) => /pro/.test(n) && /not found/i.test(n)),
|
|
310
|
+
`expected a "repo not found" note, got: ${JSON.stringify(result.notes)}`,
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('--dry passes dry through to setup (no writes)', async () => {
|
|
315
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
316
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
317
|
+
await installProjectHooks(projectRoot, { preset: 'gentle' });
|
|
318
|
+
const result = await runUpdate({
|
|
319
|
+
projectRoot,
|
|
320
|
+
runeRoot,
|
|
321
|
+
args: { dry: true, 'no-pull': true },
|
|
322
|
+
deps: { skillTarget: runeRoot, wellKnownPaths: { pro: [], business: [] } },
|
|
323
|
+
});
|
|
324
|
+
assert.strictEqual(result.ok, true);
|
|
325
|
+
assert.strictEqual(result.setup.written, false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('flags Codex re-trust when .codex/hooks.json content changed', async () => {
|
|
329
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
330
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
331
|
+
await mkdir(path.join(projectRoot, '.codex'), { recursive: true });
|
|
332
|
+
// Stale/no hooks file → setup rewrite will change it.
|
|
333
|
+
const result = await runUpdate({
|
|
334
|
+
projectRoot,
|
|
335
|
+
runeRoot,
|
|
336
|
+
args: { 'no-pull': true },
|
|
337
|
+
deps: { skillTarget: runeRoot, wellKnownPaths: { pro: [], business: [] } },
|
|
338
|
+
});
|
|
339
|
+
assert.strictEqual(result.ok, true);
|
|
340
|
+
assert.strictEqual(result.codexReTrust, true);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('formatUpdateResult', () => {
|
|
345
|
+
test('renders pull, setup, and verification summary', () => {
|
|
346
|
+
const out = formatUpdateResult({
|
|
347
|
+
ok: true,
|
|
348
|
+
pull: {
|
|
349
|
+
ok: true,
|
|
350
|
+
results: [
|
|
351
|
+
{ tier: 'pro', status: 'pulled', detail: 'Already up to date.' },
|
|
352
|
+
{ tier: 'business', status: 'absent', detail: 'not detected' },
|
|
353
|
+
],
|
|
354
|
+
},
|
|
355
|
+
platforms: ['claude'],
|
|
356
|
+
preset: 'gentle',
|
|
357
|
+
tiers: ['pro'],
|
|
358
|
+
setup: {
|
|
359
|
+
scope: 'current',
|
|
360
|
+
targetRoot: '/p',
|
|
361
|
+
tiers: ['pro'],
|
|
362
|
+
preset: 'gentle',
|
|
363
|
+
platforms: ['claude'],
|
|
364
|
+
written: true,
|
|
365
|
+
skillResults: [],
|
|
366
|
+
},
|
|
367
|
+
drift: {
|
|
368
|
+
findings: [],
|
|
369
|
+
summary: { drifted: 0, missing: 0, errors: 0 },
|
|
370
|
+
platforms: [{ platform: 'claude', preset: 'gentle' }],
|
|
371
|
+
},
|
|
372
|
+
doctor: { skipped: true, reason: 'no rune.config.json' },
|
|
373
|
+
codexReTrust: false,
|
|
374
|
+
notes: [],
|
|
375
|
+
});
|
|
376
|
+
assert.match(out, /Rune Update/);
|
|
377
|
+
assert.match(out, /pro.*pulled/i);
|
|
378
|
+
assert.match(out, /business.*not detected/i);
|
|
379
|
+
assert.match(out, /preset.*gentle/i);
|
|
380
|
+
assert.match(out, /0 drifted, 0 missing/);
|
|
381
|
+
assert.doesNotMatch(out, /\/hooks/);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('renders the Codex re-trust reminder when hooks.json changed', () => {
|
|
385
|
+
const out = formatUpdateResult({
|
|
386
|
+
ok: true,
|
|
387
|
+
pull: { ok: true, results: [] },
|
|
388
|
+
platforms: ['codex'],
|
|
389
|
+
preset: 'gentle',
|
|
390
|
+
tiers: [],
|
|
391
|
+
setup: { platforms: ['codex'], written: true, skillResults: [] },
|
|
392
|
+
drift: { findings: [], summary: { drifted: 0, missing: 0, errors: 0 }, platforms: [] },
|
|
393
|
+
doctor: { skipped: true, reason: 'no rune.config.json' },
|
|
394
|
+
codexReTrust: true,
|
|
395
|
+
notes: [],
|
|
396
|
+
});
|
|
397
|
+
assert.match(out, /\/hooks/);
|
|
398
|
+
assert.match(out, /re-trust/i);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('renders loud failure for a failed pull', () => {
|
|
402
|
+
const out = formatUpdateResult({
|
|
403
|
+
ok: false,
|
|
404
|
+
reason: 'tier pull failed — resolve manually and re-run `rune update`',
|
|
405
|
+
pull: {
|
|
406
|
+
ok: false,
|
|
407
|
+
results: [{ tier: 'pro', status: 'failed', detail: 'error: Your local changes would be overwritten' }],
|
|
408
|
+
},
|
|
409
|
+
notes: [],
|
|
410
|
+
});
|
|
411
|
+
assert.match(out, /✗/);
|
|
412
|
+
assert.match(out, /pro/);
|
|
413
|
+
assert.match(out, /local changes/);
|
|
414
|
+
assert.match(out, /re-run/i);
|
|
415
|
+
});
|
|
416
|
+
});
|