@rune-kit/rune 2.29.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 +1 -1
- package/README.md +45 -2
- package/compiler/__tests__/update.test.js +416 -0
- package/compiler/bin/rune.js +19 -0
- package/compiler/commands/update.js +354 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rune",
|
|
3
|
-
"version": "2.29.
|
|
3
|
+
"version": "2.29.1",
|
|
4
4
|
"description": "66-skill engineering mesh with Codex-native skills, subagents, synchronous lifecycle hooks, security gates, MCP-aware workflows, and project memory.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Rune Contributors"
|
package/README.md
CHANGED
|
@@ -103,11 +103,15 @@ Plus **9 domain packs** (product, sales, data-science, support, growth, media, p
|
|
|
103
103
|
|
|
104
104
|
---
|
|
105
105
|
|
|
106
|
-
## What's New (v2.29.
|
|
106
|
+
## What's New (v2.29.1 — One-Command Update)
|
|
107
|
+
|
|
108
|
+
> **v2.29.1 (2026-07-24):** v2.29.0 documented the update flow; this release automates it. New **`rune update`** command — one-shot updater for an already-configured project: `git pull --ff-only` any detected Pro/Business tier repos (env var → sibling dir, same detection as setup; a failed pull **aborts loudly**, never a silent half-update), re-runs the managed setup rewrite in place non-interactively (your installed platforms, preset, and tiers are detected from the existing hook config — no prompts), then verifies with doctor + hook drift and reminds Codex users to re-trust `/hooks` only when `.codex/hooks.json` actually changed. Flags: `--no-pull`, `--preset`, `--tier`, `--dry`. Plus: "Updating" sections in all three tier READMEs. Docs-and-CLI patch — no skill or mesh changes. 1,638 tests.
|
|
109
|
+
|
|
110
|
+
### Previous (v2.29.0 — Codex Native)
|
|
107
111
|
|
|
108
112
|
> **v2.29.0 (2026-07-23):** Codex stops being a compile target that happens to work and becomes a **first-class native runtime**. The Codex adapter now emits everything current Codex supports natively — `.agents/skills/`, project-scoped agent TOML (`.codex/agents/rune-{heavy,standard,fast}.toml`), a dedicated synchronous hook adapter targeting `.codex/hooks.json` (Codex silently skips async handlers), MCP config, and tier-aware compilation so Free/Pro/Business stacks resolve exactly as on Claude Code. The mesh validator was fixed at the root — `Calls (outbound)` is now the single authoritative edge inventory, acknowledged by the target's `Called By` — moving the canonical count from 209 to **248 connections** (same mesh, honest count; doctor now fails CI on any stale claim). Doctor also grew a cross-tier audit: Business metadata (28 pack skills, 4 orchestrators), $149 pricing, 13-platform count, and JSON-schema validation of the new `docs/config-schema.json` + hooks manifest schema. All XLabs remote-MCP references now go through `XLABS_MCP_TOKEN` — plaintext bearer values are forbidden. 1,615 tests pass.
|
|
109
113
|
|
|
110
|
-
|
|
114
|
+
#### Earlier (v2.28.0 — Reasoner's Blind Spots)
|
|
111
115
|
|
|
112
116
|
> **v2.28.0 (2026-07-22):** Completes the reasoning wave. Every addition targets one failure class: **a check that feels done because the model re-read its own work and agreed with itself.** `problem-solver` (v0.6.0) gains a **model failure-mode table** beside its human-bias table — pattern-match satisfaction, template hijack, fluent≠true, prior-as-fact, completion pressure, surface blindness — plus three tells that you are inside one right now (instant confident answer; a stated detail your draft never used; two failed attempts in the same framing). `verification` (v0.8.0) gains the **Constraint Loop** for deliverables carrying a mechanically checkable constraint on their own surface form (banned characters, exact counts, strict formats) — a class Rune had no coverage for: expand the constraint before drafting, verify with a tool, re-scan the whole artifact, ship byte-for-byte. `design` (v0.9.0) gains **render blindness** — a checklist item ticked from source is a prediction, and the imagined render is always flattering; visual items are marked 👁 and are ticked from a render or marked ASSUMED. Advisory throughout, no new skills.
|
|
113
117
|
|
|
@@ -407,6 +411,45 @@ This compiles all 66 skills into your IDE's rules format. Same knowledge, same w
|
|
|
407
411
|
lifecycle hooks; the remaining adapters preserve the workflows and constraints in the best
|
|
408
412
|
format each runtime supports.
|
|
409
413
|
|
|
414
|
+
## Updating
|
|
415
|
+
|
|
416
|
+
New releases are announced on [Telegram](https://t.me/xlabs_updates) and [GitHub Releases](https://github.com/rune-kit/rune/releases); see [`CHANGELOG.md`](CHANGELOG.md) for what changed.
|
|
417
|
+
|
|
418
|
+
> **One-liner alternative**: `npx @rune-kit/rune@latest update` runs the whole flow below for an already-configured project — git-pulls detected Pro/Business repos, re-runs the managed setup rewrite in place (reusing your installed platforms/preset/tiers), and verifies with doctor. The platform-specific commands below remain the canonical path.
|
|
419
|
+
|
|
420
|
+
### Claude Code (plugin)
|
|
421
|
+
|
|
422
|
+
```bash
|
|
423
|
+
claude plugin update rune
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Restart Claude Code to load the new version, then re-run the wizard once so hooks and tier skills match the new release:
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
npx @rune-kit/rune@latest setup
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Codex / Cursor / Windsurf / other compiled platforms
|
|
433
|
+
|
|
434
|
+
Re-running setup with `@latest` fetches the newest compiler and rewrites everything Rune manages **in place** — it is idempotent, and never touches your own config or the human-authored parts of `AGENTS.md`:
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
# same flags you installed with, e.g.:
|
|
438
|
+
npx @rune-kit/rune@latest setup --here
|
|
439
|
+
npx @rune-kit/rune@latest setup --here --platform codex --tier pro
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
If you use the build pipeline (`rune init` route) instead of the wizard: `npx @rune-kit/rune@latest init --force` regenerates platform files without touching your `.rune/` state.
|
|
443
|
+
|
|
444
|
+
### Verify after updating
|
|
445
|
+
|
|
446
|
+
```bash
|
|
447
|
+
npx @rune-kit/rune@latest doctor # compiled output + mesh integrity
|
|
448
|
+
npx @rune-kit/rune@latest doctor --hooks # hook drift vs the canonical preset
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
On Codex, review and re-trust changed hooks with `/hooks`.
|
|
452
|
+
|
|
410
453
|
## Quick Start
|
|
411
454
|
|
|
412
455
|
```bash
|
|
@@ -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
|
+
});
|
package/compiler/bin/rune.js
CHANGED
|
@@ -25,6 +25,7 @@ import { installHooks } from '../commands/hooks/install.js';
|
|
|
25
25
|
import { hookStatus } from '../commands/hooks/status.js';
|
|
26
26
|
import { uninstallHooks } from '../commands/hooks/uninstall.js';
|
|
27
27
|
import { formatSetupResult, runSetup } from '../commands/setup.js';
|
|
28
|
+
import { formatUpdateResult, runUpdate } from '../commands/update.js';
|
|
28
29
|
import { generateComprehensionHTML } from '../comprehension.js';
|
|
29
30
|
import { generateDashboardHTML } from '../dashboard.js';
|
|
30
31
|
import { checkMeshIntegrity, formatDoctorResults, formatMeshResults, runDoctor } from '../doctor.js';
|
|
@@ -338,6 +339,18 @@ async function cmdSetup(projectRoot, args) {
|
|
|
338
339
|
}
|
|
339
340
|
}
|
|
340
341
|
|
|
342
|
+
async function cmdUpdate(projectRoot, args) {
|
|
343
|
+
try {
|
|
344
|
+
const result = await runUpdate({ projectRoot, runeRoot: RUNE_ROOT, args });
|
|
345
|
+
log(formatUpdateResult(result));
|
|
346
|
+
if (!result.ok) process.exit(1);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
log('');
|
|
349
|
+
log(` ✗ Update failed: ${err.message}`);
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
341
354
|
async function readVersion() {
|
|
342
355
|
try {
|
|
343
356
|
const pkg = JSON.parse(await readFile(path.join(RUNE_ROOT, 'package.json'), 'utf-8'));
|
|
@@ -852,6 +865,9 @@ async function main() {
|
|
|
852
865
|
case 'setup':
|
|
853
866
|
await cmdSetup(projectRoot, args);
|
|
854
867
|
break;
|
|
868
|
+
case 'update':
|
|
869
|
+
await cmdUpdate(projectRoot, args);
|
|
870
|
+
break;
|
|
855
871
|
case 'status':
|
|
856
872
|
await cmdStatus(projectRoot, args);
|
|
857
873
|
break;
|
|
@@ -887,6 +903,9 @@ async function main() {
|
|
|
887
903
|
' setup Interactive wizard — auto-detect tiers, pick scope, install hooks (recommended for first-time)',
|
|
888
904
|
);
|
|
889
905
|
log(' [--here|--global] [--tier pro,business] [--preset gentle|strict] [--dry]');
|
|
906
|
+
log(' update Update an existing install — git-pull detected tier repos, re-run the managed');
|
|
907
|
+
log(' setup rewrite in place (reuses installed platforms/preset/tiers), verify with doctor');
|
|
908
|
+
log(' [--no-pull] [--preset gentle|strict] [--tier pro,business] [--dry]');
|
|
890
909
|
log(' init Interactive setup for build pipeline (auto-detects platform)');
|
|
891
910
|
log(' build Compile skills for configured platform');
|
|
892
911
|
log(' doctor Validate compiled output + mesh integrity');
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rune update` — one-shot updater for an already-configured project.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the manual "Updating" flow from the README in a single command:
|
|
5
|
+
* 1. `git pull --ff-only` any detected paid tier repos (Pro / Business).
|
|
6
|
+
* Detection reuses setup's logic: $RUNE_PRO_ROOT / $RUNE_BUSINESS_ROOT
|
|
7
|
+
* env vars, then sibling dirs (../Pro, ../Business), then well-known
|
|
8
|
+
* paths. Absent tiers and non-git checkouts are skipped with a note;
|
|
9
|
+
* a FAILED pull aborts the update (fail loud — never silently continue).
|
|
10
|
+
* 2. Re-run the managed setup rewrite in place, non-interactively: the
|
|
11
|
+
* installed platforms, preset, and tiers are detected from the project's
|
|
12
|
+
* existing hook config instead of prompting. Delegates to `runSetup`.
|
|
13
|
+
* 3. Verify: compiled-output doctor (when a rune.config.json build exists)
|
|
14
|
+
* + hook drift report, then print a short summary. Codex users are
|
|
15
|
+
* reminded to re-trust hooks via `/hooks` when `.codex/hooks.json`
|
|
16
|
+
* changed.
|
|
17
|
+
*
|
|
18
|
+
* Non-interactive by design — safe to run from scripts. Flags:
|
|
19
|
+
* --no-pull skip step 1 (tier repos managed some other way)
|
|
20
|
+
* --preset <p> override the detected preset
|
|
21
|
+
* --tier <t>[,<t>] override the detected tier list
|
|
22
|
+
* --dry preview: skip pulls, pass dry to setup (no writes)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execFile } from 'node:child_process';
|
|
26
|
+
import { existsSync } from 'node:fs';
|
|
27
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { ADAPTERS, detectPlatforms } from '../adapters/hooks/index.js';
|
|
30
|
+
import { checkHookDrift } from './hooks/drift.js';
|
|
31
|
+
import { TIER_ENV_VARS } from './hooks/tiers.js';
|
|
32
|
+
import { detectTiers, runSetup } from './setup.js';
|
|
33
|
+
|
|
34
|
+
/** Installed hook configs that may carry tier tokens (env var references). */
|
|
35
|
+
const TIER_SCAN_FILES = ['.claude/settings.json', '.codex/hooks.json'];
|
|
36
|
+
|
|
37
|
+
/** Rule/workflow dirs whose Rune-managed files carry `rune-tier:` frontmatter. */
|
|
38
|
+
const TIER_SCAN_DIRS = ['.cursor/rules', '.windsurf/rules', '.windsurf/workflows', '.antigravity/rules'];
|
|
39
|
+
|
|
40
|
+
const CODEX_HOOKS_REL_PATH = '.codex/hooks.json';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Detect which paid tiers are wired into this project's installed hook config.
|
|
44
|
+
* Reads the config files Rune manages and looks for tier env-var tokens
|
|
45
|
+
* (`RUNE_PRO_ROOT` / `RUNE_BUSINESS_ROOT`) or `rune-tier:` frontmatter.
|
|
46
|
+
* Free preset entries never contain either marker, so a Free-only install
|
|
47
|
+
* returns [].
|
|
48
|
+
*
|
|
49
|
+
* @param {string} projectRoot
|
|
50
|
+
* @returns {Promise<string[]>} tier names, e.g. ['pro']
|
|
51
|
+
*/
|
|
52
|
+
export async function detectInstalledTiers(projectRoot) {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for (const rel of TIER_SCAN_FILES) {
|
|
55
|
+
const filePath = path.join(projectRoot, rel);
|
|
56
|
+
if (existsSync(filePath)) {
|
|
57
|
+
chunks.push(await readFile(filePath, 'utf-8').catch(() => ''));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const rel of TIER_SCAN_DIRS) {
|
|
61
|
+
const dir = path.join(projectRoot, rel);
|
|
62
|
+
if (!existsSync(dir)) continue;
|
|
63
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!entry.isFile() || !/\.(md|mdc)$/.test(entry.name)) continue;
|
|
66
|
+
chunks.push(await readFile(path.join(dir, entry.name), 'utf-8').catch(() => ''));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const text = chunks.join('\n');
|
|
70
|
+
const tiers = [];
|
|
71
|
+
for (const [tier, envVar] of Object.entries(TIER_ENV_VARS)) {
|
|
72
|
+
const marker = new RegExp(`${envVar}|rune-tier:\\s*${tier}\\b`);
|
|
73
|
+
if (marker.test(text)) tiers.push(tier);
|
|
74
|
+
}
|
|
75
|
+
return tiers;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Detect the preset the project was installed with, by asking each detected
|
|
80
|
+
* platform adapter for its status. A `mixed` install resolves to `gentle`
|
|
81
|
+
* (same fallback the drift reporter uses for canonical comparison).
|
|
82
|
+
*
|
|
83
|
+
* @param {string} projectRoot
|
|
84
|
+
* @returns {Promise<'gentle'|'strict'|null>} null when nothing is installed
|
|
85
|
+
*/
|
|
86
|
+
export async function detectInstalledPreset(projectRoot) {
|
|
87
|
+
for (const id of detectPlatforms(projectRoot)) {
|
|
88
|
+
const status = await ADAPTERS[id].status(projectRoot);
|
|
89
|
+
if (!status.installed || !status.preset) continue;
|
|
90
|
+
if (status.preset === 'mixed') return 'gentle';
|
|
91
|
+
if (status.preset === 'gentle' || status.preset === 'strict') return status.preset;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Default command runner — thin execFile wrapper that never throws.
|
|
98
|
+
* @returns {Promise<{code: number, stdout: string, stderr: string}>}
|
|
99
|
+
*/
|
|
100
|
+
function defaultExec(cmd, argv) {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
execFile(cmd, argv, { windowsHide: true }, (err, stdout, stderr) => {
|
|
103
|
+
if (err) {
|
|
104
|
+
resolve({
|
|
105
|
+
code: typeof err.code === 'number' ? err.code : 1,
|
|
106
|
+
stdout: stdout ?? '',
|
|
107
|
+
stderr: stderr || err.message,
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
resolve({ code: 0, stdout: stdout ?? '', stderr: stderr ?? '' });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `git pull --ff-only` each detected tier repo.
|
|
118
|
+
*
|
|
119
|
+
* Statuses per tier:
|
|
120
|
+
* - absent — tier not detected (no env var / sibling / well-known path)
|
|
121
|
+
* - skipped — detected but not a git checkout (npx/tarball install)
|
|
122
|
+
* - pulled — git pull succeeded
|
|
123
|
+
* - failed — git pull failed (dirty tree, auth, diverged…) → result.ok=false
|
|
124
|
+
*
|
|
125
|
+
* @param {{ detected: ReturnType<typeof detectTiers>, exec?: typeof defaultExec }} opts
|
|
126
|
+
* @returns {Promise<{ ok: boolean, results: Array<{tier: string, root: string|null, status: string, detail: string}> }>}
|
|
127
|
+
*/
|
|
128
|
+
export async function pullTierRepos({ detected, exec = defaultExec }) {
|
|
129
|
+
const results = [];
|
|
130
|
+
for (const tier of Object.keys(TIER_ENV_VARS)) {
|
|
131
|
+
const info = detected[tier];
|
|
132
|
+
if (!info) {
|
|
133
|
+
results.push({ tier, root: null, status: 'absent', detail: 'not detected — skipping' });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
// detectTiers points at <tierRoot>/hooks/manifest.json — walk back to the root.
|
|
137
|
+
const tierRoot = path.resolve(path.dirname(path.dirname(info.path)));
|
|
138
|
+
if (!existsSync(path.join(tierRoot, '.git'))) {
|
|
139
|
+
results.push({
|
|
140
|
+
tier,
|
|
141
|
+
root: tierRoot,
|
|
142
|
+
status: 'skipped',
|
|
143
|
+
detail: `${tierRoot} is not a git repo — pull skipped (update it the way it was installed)`,
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const { code, stdout, stderr } = await exec('git', ['-C', tierRoot, 'pull', '--ff-only']);
|
|
148
|
+
if (code === 0) {
|
|
149
|
+
const summary = (stdout.trim().split('\n').pop() || 'done').trim();
|
|
150
|
+
results.push({ tier, root: tierRoot, status: 'pulled', detail: summary });
|
|
151
|
+
} else {
|
|
152
|
+
results.push({ tier, root: tierRoot, status: 'failed', detail: (stderr || stdout).trim() });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { ok: results.every((r) => r.status !== 'failed'), results };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Compiled-output verification. Only meaningful for build-pipeline projects
|
|
160
|
+
* (rune.config.json with a non-Claude platform) — Claude Code loads source
|
|
161
|
+
* SKILL.md files natively, so there is no compiled output to check.
|
|
162
|
+
*/
|
|
163
|
+
async function defaultDoctorCheck(projectRoot, runeRoot) {
|
|
164
|
+
const configPath = path.join(projectRoot, 'rune.config.json');
|
|
165
|
+
if (!existsSync(configPath)) {
|
|
166
|
+
return { skipped: true, reason: 'no rune.config.json — compiled-output check skipped' };
|
|
167
|
+
}
|
|
168
|
+
let config;
|
|
169
|
+
try {
|
|
170
|
+
config = JSON.parse(await readFile(configPath, 'utf-8'));
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return { skipped: true, reason: `rune.config.json unreadable (${err.message}) — compiled-output check skipped` };
|
|
173
|
+
}
|
|
174
|
+
if (!config.platform || config.platform === 'claude') {
|
|
175
|
+
return { skipped: true, reason: 'Claude Code native — no compiled output to check' };
|
|
176
|
+
}
|
|
177
|
+
const { runDoctor } = await import('../doctor.js');
|
|
178
|
+
const { getAdapter } = await import('../adapters/index.js');
|
|
179
|
+
const resolvedRuneRoot = config.source === '@rune-kit/rune' ? runeRoot : config.source || runeRoot;
|
|
180
|
+
const results = await runDoctor({
|
|
181
|
+
outputRoot: projectRoot,
|
|
182
|
+
adapter: getAdapter(config.platform),
|
|
183
|
+
config,
|
|
184
|
+
runeRoot: resolvedRuneRoot,
|
|
185
|
+
});
|
|
186
|
+
return { skipped: false, healthy: results.healthy, results };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The full update flow. Pure orchestration — every side-effecting collaborator
|
|
191
|
+
* is injectable via `deps` for tests.
|
|
192
|
+
*
|
|
193
|
+
* @param {object} opts
|
|
194
|
+
* @param {string} opts.projectRoot
|
|
195
|
+
* @param {string} opts.runeRoot
|
|
196
|
+
* @param {object} [opts.args] — CLI flags: no-pull, preset, tier, dry
|
|
197
|
+
* @param {object} [opts.deps] — { exec, runSetupFn, checkHookDriftFn, doctorFn, skillTarget, wellKnownPaths }
|
|
198
|
+
* @returns {Promise<object>} structured result for formatUpdateResult
|
|
199
|
+
*/
|
|
200
|
+
export async function runUpdate({ projectRoot, runeRoot, args = {}, deps = {} }) {
|
|
201
|
+
const notes = [];
|
|
202
|
+
|
|
203
|
+
// ── 0. Something to update? ──
|
|
204
|
+
const platforms = detectPlatforms(projectRoot);
|
|
205
|
+
if (platforms.length === 0) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
reason:
|
|
209
|
+
'No Rune installation detected in this project — nothing to update. Run `npx @rune-kit/rune setup` first.',
|
|
210
|
+
pull: { ok: true, results: [] },
|
|
211
|
+
notes,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── 1. Pull tier repos (fail loud on pull errors) ──
|
|
216
|
+
const detected = detectTiers(projectRoot, deps.wellKnownPaths ? { wellKnownPaths: deps.wellKnownPaths } : {});
|
|
217
|
+
let pull;
|
|
218
|
+
if (args['no-pull'] || args.dry) {
|
|
219
|
+
const why = args['no-pull'] ? '--no-pull' : '--dry';
|
|
220
|
+
pull = {
|
|
221
|
+
ok: true,
|
|
222
|
+
results: Object.keys(TIER_ENV_VARS).map((tier) => ({
|
|
223
|
+
tier,
|
|
224
|
+
root: null,
|
|
225
|
+
status: detected[tier] ? 'skipped' : 'absent',
|
|
226
|
+
detail: detected[tier] ? `pull skipped (${why})` : 'not detected — skipping',
|
|
227
|
+
})),
|
|
228
|
+
};
|
|
229
|
+
} else {
|
|
230
|
+
pull = await pullTierRepos({ detected, exec: deps.exec });
|
|
231
|
+
}
|
|
232
|
+
if (!pull.ok) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
reason: 'tier pull failed — resolve it manually (dirty tree? auth?) then re-run `rune update`',
|
|
236
|
+
pull,
|
|
237
|
+
notes,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── 2. Re-run managed setup, reusing what is already installed ──
|
|
242
|
+
const preset = args.preset || (await detectInstalledPreset(projectRoot)) || 'gentle';
|
|
243
|
+
let tiers;
|
|
244
|
+
if (args.tier) {
|
|
245
|
+
tiers = String(args.tier)
|
|
246
|
+
.split(',')
|
|
247
|
+
.map((t) => t.trim())
|
|
248
|
+
.filter(Boolean);
|
|
249
|
+
} else {
|
|
250
|
+
const installed = await detectInstalledTiers(projectRoot);
|
|
251
|
+
tiers = installed.filter((tier) => detected[tier]);
|
|
252
|
+
for (const tier of installed) {
|
|
253
|
+
if (!detected[tier]) {
|
|
254
|
+
const envVar = TIER_ENV_VARS[tier] || `RUNE_${tier.toUpperCase()}_ROOT`;
|
|
255
|
+
notes.push(
|
|
256
|
+
`${tier}: hooks are installed but the tier repo was not found — skipping. Set $${envVar} or clone the repo next to Free, then re-run \`rune update\`.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const codexHooksPath = path.join(projectRoot, CODEX_HOOKS_REL_PATH);
|
|
263
|
+
const codexBefore = existsSync(codexHooksPath) ? await readFile(codexHooksPath, 'utf-8').catch(() => null) : null;
|
|
264
|
+
|
|
265
|
+
const setupArgs = {
|
|
266
|
+
here: true,
|
|
267
|
+
preset,
|
|
268
|
+
dry: args.dry,
|
|
269
|
+
...(tiers.length > 0 ? { tier: tiers.join(',') } : { 'no-tier': true }),
|
|
270
|
+
};
|
|
271
|
+
const setup = await (deps.runSetupFn || runSetup)({
|
|
272
|
+
projectRoot,
|
|
273
|
+
runeRoot,
|
|
274
|
+
args: setupArgs,
|
|
275
|
+
skillTarget: deps.skillTarget,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const codexAfter = existsSync(codexHooksPath) ? await readFile(codexHooksPath, 'utf-8').catch(() => null) : null;
|
|
279
|
+
const codexReTrust = platforms.includes('codex') && codexBefore !== codexAfter;
|
|
280
|
+
|
|
281
|
+
// ── 3. Verify: compiled output + hook drift ──
|
|
282
|
+
const doctor = await (deps.doctorFn || defaultDoctorCheck)(projectRoot, runeRoot);
|
|
283
|
+
const drift = await (deps.checkHookDriftFn || checkHookDrift)(projectRoot);
|
|
284
|
+
|
|
285
|
+
return { ok: true, pull, platforms, preset, tiers, setup, drift, doctor, codexReTrust, notes };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const PULL_ICONS = { pulled: '✓', absent: '·', skipped: '·', failed: '✗' };
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Render a runUpdate result for console output.
|
|
292
|
+
* @param {Awaited<ReturnType<typeof runUpdate>>} result
|
|
293
|
+
* @returns {string}
|
|
294
|
+
*/
|
|
295
|
+
export function formatUpdateResult(result) {
|
|
296
|
+
const lines = [];
|
|
297
|
+
lines.push('');
|
|
298
|
+
lines.push(' Rune Update');
|
|
299
|
+
lines.push(' ────────────');
|
|
300
|
+
|
|
301
|
+
if (result.pull?.results?.length > 0) {
|
|
302
|
+
lines.push(' Tier repos:');
|
|
303
|
+
for (const r of result.pull.results) {
|
|
304
|
+
const icon = PULL_ICONS[r.status] || '·';
|
|
305
|
+
const label = r.status === 'failed' ? 'pull FAILED' : r.status;
|
|
306
|
+
lines.push(` ${icon} ${r.tier} — ${label}: ${r.detail}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!result.ok) {
|
|
311
|
+
lines.push('');
|
|
312
|
+
lines.push(` ✗ Update aborted: ${result.reason}`);
|
|
313
|
+
lines.push('');
|
|
314
|
+
return lines.join('\n');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
lines.push(' Setup rewrite:');
|
|
318
|
+
lines.push(
|
|
319
|
+
` Platforms: ${(result.setup?.platforms || result.platforms || []).join(', ') || '—'} | Preset: ${result.preset} | Tiers: Free${result.tiers?.length ? ` + ${result.tiers.join(' + ')}` : ''}`,
|
|
320
|
+
);
|
|
321
|
+
for (const sr of result.setup?.skillResults || []) {
|
|
322
|
+
if (sr.installed?.length > 0) {
|
|
323
|
+
lines.push(` Skills: ${sr.tier}: ${sr.installed.length} installed`);
|
|
324
|
+
} else if (sr.skipped?.length > 0) {
|
|
325
|
+
lines.push(` Skills: ${sr.tier}: ${sr.skipped.length} already present / skipped`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (result.setup && result.setup.written === false) {
|
|
329
|
+
lines.push(' (dry-run — no files written)');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
lines.push(' Verify:');
|
|
333
|
+
if (result.doctor?.skipped) {
|
|
334
|
+
lines.push(` Doctor: skipped — ${result.doctor.reason}`);
|
|
335
|
+
} else if (result.doctor) {
|
|
336
|
+
lines.push(` Doctor: ${result.doctor.healthy ? '✓ healthy' : '✗ issues found — run `rune doctor` for detail'}`);
|
|
337
|
+
}
|
|
338
|
+
if (result.drift?.summary) {
|
|
339
|
+
const s = result.drift.summary;
|
|
340
|
+
lines.push(` Hook drift: ${s.drifted} drifted, ${s.missing} missing, ${s.errors} error(s)`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const note of result.notes || []) {
|
|
344
|
+
lines.push(` ⚠ ${note}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (result.codexReTrust) {
|
|
348
|
+
lines.push('');
|
|
349
|
+
lines.push(' ⚠ Codex: .codex/hooks.json changed — open /hooks in Codex to review and re-trust the definitions.');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
lines.push('');
|
|
353
|
+
return lines.join('\n');
|
|
354
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rune-kit/rune",
|
|
3
|
-
"version": "2.29.
|
|
3
|
+
"version": "2.29.1",
|
|
4
4
|
"description": "66-skill mesh for AI coding assistants — native lifecycle hooks for Claude Code and Codex, 5-layer architecture, 248 connections + 45 signals, and a 13-platform compiler.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|