@bookedsolid/rea 0.4.0 → 0.6.0
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/dist/audit/append.js +12 -1
- package/dist/cache/review-cache.d.ts +115 -0
- package/dist/cache/review-cache.js +200 -0
- package/dist/cli/cache.d.ts +52 -0
- package/dist/cli/cache.js +112 -0
- package/dist/cli/doctor.d.ts +27 -0
- package/dist/cli/doctor.js +85 -3
- package/dist/cli/index.js +41 -0
- package/dist/cli/init.js +16 -0
- package/dist/cli/install/gitignore.d.ts +114 -0
- package/dist/cli/install/gitignore.js +356 -0
- package/dist/cli/upgrade.js +20 -0
- package/dist/gateway/downstream-pool.d.ts +34 -0
- package/dist/gateway/downstream-pool.js +37 -0
- package/dist/gateway/downstream.d.ts +11 -0
- package/dist/gateway/downstream.js +36 -5
- package/dist/gateway/meta/health.d.ts +117 -0
- package/dist/gateway/meta/health.js +108 -0
- package/dist/gateway/server.js +109 -12
- package/dist/policy/loader.d.ts +10 -0
- package/dist/policy/loader.js +2 -0
- package/dist/policy/types.d.ts +20 -0
- package/hooks/push-review-gate.sh +185 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { runAuditRotate, runAuditVerify } from './audit.js';
|
|
4
|
+
import { parseCacheResult, runCacheCheck, runCacheClear, runCacheList, runCacheSet, } from './cache.js';
|
|
4
5
|
import { runCheck } from './check.js';
|
|
5
6
|
import { runDoctor } from './doctor.js';
|
|
6
7
|
import { runFreeze, runUnfreeze } from './freeze.js';
|
|
@@ -101,6 +102,46 @@ async function main() {
|
|
|
101
102
|
.action(async (opts) => {
|
|
102
103
|
await runAuditVerify({ ...(opts.since !== undefined ? { since: opts.since } : {}) });
|
|
103
104
|
});
|
|
105
|
+
const cache = program
|
|
106
|
+
.command('cache')
|
|
107
|
+
.description('Review-cache operations — check/set/clear/list .rea/review-cache.jsonl (BUG-009). Used by hooks/push-review-gate.sh to skip re-review on a previously-approved diff.');
|
|
108
|
+
cache
|
|
109
|
+
.command('check <sha>')
|
|
110
|
+
.description('Look up a cache entry. Emits JSON to stdout ONLY — hook contract. On hit: {hit,true,result,branch,base,recorded_at[,reason]}. On miss: {hit:false}. Never exits non-zero for normal miss.')
|
|
111
|
+
.requiredOption('--branch <branch>', 'feature branch being pushed')
|
|
112
|
+
.requiredOption('--base <base>', 'base branch the feature targets')
|
|
113
|
+
.action(async (sha, opts) => {
|
|
114
|
+
await runCacheCheck({ sha, branch: opts.branch, base: opts.base });
|
|
115
|
+
});
|
|
116
|
+
cache
|
|
117
|
+
.command('set <sha> <result>')
|
|
118
|
+
.description('Record a review outcome. <result> must be "pass" or "fail". Idempotent line-per-invocation; last write wins on (sha, branch, base).')
|
|
119
|
+
.requiredOption('--branch <branch>', 'feature branch being pushed')
|
|
120
|
+
.requiredOption('--base <base>', 'base branch the feature targets')
|
|
121
|
+
.option('--reason <text>', 'free-text context for this entry (recommended on fail)')
|
|
122
|
+
.action(async (sha, rawResult, opts) => {
|
|
123
|
+
const result = parseCacheResult(rawResult);
|
|
124
|
+
await runCacheSet({
|
|
125
|
+
sha,
|
|
126
|
+
result,
|
|
127
|
+
branch: opts.branch,
|
|
128
|
+
base: opts.base,
|
|
129
|
+
...(opts.reason !== undefined ? { reason: opts.reason } : {}),
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
cache
|
|
133
|
+
.command('clear <sha>')
|
|
134
|
+
.description('Remove every cache entry matching <sha>. Dev convenience — prints the removed count.')
|
|
135
|
+
.action(async (sha) => {
|
|
136
|
+
await runCacheClear({ sha });
|
|
137
|
+
});
|
|
138
|
+
cache
|
|
139
|
+
.command('list')
|
|
140
|
+
.description('Print cache entries in file order. Filter with --branch.')
|
|
141
|
+
.option('--branch <branch>', 'only list entries for this branch')
|
|
142
|
+
.action(async (opts) => {
|
|
143
|
+
await runCacheList({ ...(opts.branch !== undefined ? { branch: opts.branch } : {}) });
|
|
144
|
+
});
|
|
104
145
|
program
|
|
105
146
|
.command('doctor')
|
|
106
147
|
.description('Validate the install: policy parses, .rea/ layout, hooks, Codex plugin.')
|
package/dist/cli/init.js
CHANGED
|
@@ -5,6 +5,7 @@ import * as p from '@clack/prompts';
|
|
|
5
5
|
import { AutonomyLevel } from '../policy/types.js';
|
|
6
6
|
import { HARD_DEFAULTS, loadProfile, mergeProfiles } from '../policy/profiles.js';
|
|
7
7
|
import { copyArtifacts } from './install/copy.js';
|
|
8
|
+
import { ensureReaGitignore } from './install/gitignore.js';
|
|
8
9
|
import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
|
|
9
10
|
import { installCommitMsgHook } from './install/commit-msg.js';
|
|
10
11
|
import { installPrePushFallback } from './install/pre-push.js';
|
|
@@ -464,6 +465,10 @@ export async function runInit(options) {
|
|
|
464
465
|
blockAiAttribution: config.blockAiAttribution,
|
|
465
466
|
};
|
|
466
467
|
const mdResult = await writeClaudeMdFragment(targetDir, fragmentInput);
|
|
468
|
+
// BUG-010 — scaffold `.gitignore` entries for every runtime artifact
|
|
469
|
+
// `rea serve` / `rea cache` / `/freeze` can write under `.rea/`. Idempotent
|
|
470
|
+
// append (and `rea upgrade` backfills older installs that never got this).
|
|
471
|
+
const gitignoreResult = await ensureReaGitignore(targetDir);
|
|
467
472
|
// G12 — record the install manifest. SHAs are of the files actually on disk
|
|
468
473
|
// after the copy pass, so drift detection compares against real state (not
|
|
469
474
|
// canonical, which may differ if the consumer's copy was aborted mid-run).
|
|
@@ -487,6 +492,17 @@ export async function runInit(options) {
|
|
|
487
492
|
console.log(` = ${path.relative(targetDir, prePushResult.decision.hookPath)} (active pre-push already present — skipped fallback)`);
|
|
488
493
|
}
|
|
489
494
|
console.log(` ${mdResult.replaced ? '~' : '+'} ${path.relative(targetDir, mdResult.path)} (fragment ${mdResult.replaced ? 'replaced' : 'written'})`);
|
|
495
|
+
if (gitignoreResult.action === 'created') {
|
|
496
|
+
console.log(` + ${path.relative(targetDir, gitignoreResult.path)} (managed block written)`);
|
|
497
|
+
}
|
|
498
|
+
else if (gitignoreResult.action === 'updated') {
|
|
499
|
+
console.log(` ~ ${path.relative(targetDir, gitignoreResult.path)} (managed block ${gitignoreResult.addedEntries.length} entr${gitignoreResult.addedEntries.length === 1 ? 'y' : 'ies'} added)`);
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
console.log(` · ${path.relative(targetDir, gitignoreResult.path)} (managed block up to date)`);
|
|
503
|
+
}
|
|
504
|
+
for (const w of gitignoreResult.warnings)
|
|
505
|
+
warn(w);
|
|
490
506
|
console.log(` + ${path.relative(targetDir, manifestPath)}`);
|
|
491
507
|
if (mergeResult.warnings.length > 0) {
|
|
492
508
|
console.log('');
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BUG-010 — `.gitignore` scaffolding for rea-managed runtime artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Background. `rea serve` (G7 catalog fingerprint) writes
|
|
5
|
+
* `.rea/fingerprints.json` at startup. `rea init` in 0.4.0 and earlier never
|
|
6
|
+
* scaffolded ANY `.gitignore` entries for the consumer repo, so an operator
|
|
7
|
+
* who ran `rea init` then started the gateway would see a "new file" in
|
|
8
|
+
* `git status` that nobody told them about. Helix reported this as BUG-010.
|
|
9
|
+
*
|
|
10
|
+
* The fix is broader than fingerprints.json — every runtime artifact rea
|
|
11
|
+
* writes (under `.rea/` AND its sibling `proper-lockfile` directory at
|
|
12
|
+
* `.rea.lock`) must be in the consumer's `.gitignore`:
|
|
13
|
+
*
|
|
14
|
+
* - `.rea/audit.jsonl` — G1 hash-chained audit log (append-only)
|
|
15
|
+
* - `.rea/audit-*.jsonl` — G1 rotated audit archives
|
|
16
|
+
* - `.rea/HALT` — /freeze marker (ephemeral)
|
|
17
|
+
* - `.rea/metrics.jsonl` — G5 metrics stream
|
|
18
|
+
* - `.rea/serve.pid` — G5 `rea serve` pidfile
|
|
19
|
+
* - `.rea/serve.state.json` — G5 `rea serve` state snapshot
|
|
20
|
+
* - `.rea/fingerprints.json` — G7 downstream catalog fingerprints (BUG-010)
|
|
21
|
+
* - `.rea/review-cache.jsonl` — BUG-009 review cache (rea cache set/check)
|
|
22
|
+
* - `.rea/*.tmp` — serve temp-file-then-rename pattern
|
|
23
|
+
* - `.rea/*.tmp.*` — review-cache pid-salted temp pattern
|
|
24
|
+
* - `.rea/install-manifest.json.bak` / `.tmp` — fs-safe atomic-replace sidecars
|
|
25
|
+
* - `.gitignore.rea-tmp-*` — this module's own temp files on crash
|
|
26
|
+
* (root-level — writeAtomic stages next
|
|
27
|
+
* to .gitignore, not under .rea/)
|
|
28
|
+
* - `.rea.lock` — proper-lockfile sibling dir (NOT under .rea/)
|
|
29
|
+
* (Codex F1 on the BUG-010 review caught all three of these last groups.)
|
|
30
|
+
*
|
|
31
|
+
* Idempotency contract.
|
|
32
|
+
*
|
|
33
|
+
* - `rea init` on a fresh repo with no `.gitignore` → create one with the
|
|
34
|
+
* managed block only.
|
|
35
|
+
* - `rea init` on a repo with a `.gitignore` that has NO rea block → append
|
|
36
|
+
* a managed block separated by a blank line.
|
|
37
|
+
* - `rea upgrade` on an older install whose `.gitignore` lacks the block →
|
|
38
|
+
* same as init; backfill the block so `fingerprints.json` stops showing
|
|
39
|
+
* up as an untracked file.
|
|
40
|
+
* - `rea upgrade` where the managed block exists but is missing some new
|
|
41
|
+
* entries (e.g. `fingerprints.json`, `review-cache.jsonl` added in 0.5.0)
|
|
42
|
+
* → insert the missing lines inside the existing block, preserving any
|
|
43
|
+
* operator-authored lines within the block.
|
|
44
|
+
* - All entries already present, in any order → no-op.
|
|
45
|
+
*
|
|
46
|
+
* Operator DELETIONS of canonical entries are NOT preserved — re-running
|
|
47
|
+
* ensureReaGitignore will re-insert any canonical entry missing from the
|
|
48
|
+
* block body. To opt out of ignoring a specific artifact, operators must
|
|
49
|
+
* configure rea itself, not edit the managed block. This is intentional —
|
|
50
|
+
* the managed block is rea's territory.
|
|
51
|
+
*
|
|
52
|
+
* Security/containment.
|
|
53
|
+
*
|
|
54
|
+
* - Refuse to follow a `.gitignore` symlink (`lstat` gate before any read).
|
|
55
|
+
* The subsequent read uses `O_NOFOLLOW | O_RDONLY` so a TOCTOU swap after
|
|
56
|
+
* the lstat cannot trick us into reading through a symlink to secrets
|
|
57
|
+
* (e.g. `~/.ssh/id_rsa`) and splicing them into the written `.gitignore`.
|
|
58
|
+
* - Temp file name uses `crypto.randomBytes(16)` — not PID + Date.now, which
|
|
59
|
+
* are predictable and leak process info. (Codex F2.)
|
|
60
|
+
* - Cleanup best-effort on write failure so a stale temp file from a
|
|
61
|
+
* prior crash does not accrete.
|
|
62
|
+
*
|
|
63
|
+
* CRLF compatibility (Codex F3).
|
|
64
|
+
*
|
|
65
|
+
* Windows consumers with `core.autocrlf=true` get CRLF line endings on
|
|
66
|
+
* `.gitignore`. Without explicit handling, `"# === rea managed ==="` !==
|
|
67
|
+
* `"# === rea managed ===\r"` and every upgrade would append a duplicate
|
|
68
|
+
* block. We detect the input EOL on read, split on `\r?\n`, trim trailing
|
|
69
|
+
* whitespace from each line before marker-anchored matching, and re-emit
|
|
70
|
+
* with the detected EOL on write.
|
|
71
|
+
*
|
|
72
|
+
* Duplicate blocks (Codex F4).
|
|
73
|
+
*
|
|
74
|
+
* If the file already contains two managed blocks (from a prior bug,
|
|
75
|
+
* manual copy-paste, or two different rea versions), refuse to modify and
|
|
76
|
+
* surface a warning. Merging is more ambitious than this module needs to
|
|
77
|
+
* be — the operator resolves manually, then a subsequent run proceeds.
|
|
78
|
+
*/
|
|
79
|
+
export declare const GITIGNORE_BLOCK_START = "# === rea managed \u2014 do not edit between markers ===";
|
|
80
|
+
export declare const GITIGNORE_BLOCK_END = "# === end rea managed ===";
|
|
81
|
+
/**
|
|
82
|
+
* Ordered list of entries every rea install must gitignore. Order is stable
|
|
83
|
+
* so the scaffolded block is deterministic across runs, which in turn makes
|
|
84
|
+
* drift detection tractable: a diff in the managed block means a consumer
|
|
85
|
+
* (or another installer) edited it, not that rea reshuffled.
|
|
86
|
+
*
|
|
87
|
+
* The grouping below is by origin, not alphabetical:
|
|
88
|
+
* 1. audit + HALT + metrics (G1, G4, G5)
|
|
89
|
+
* 2. serve state (G5)
|
|
90
|
+
* 3. fingerprints (G7 / BUG-010)
|
|
91
|
+
* 4. review cache (BUG-009)
|
|
92
|
+
* 5. temp/sidecar patterns (Codex F1)
|
|
93
|
+
* 6. sibling lockfile (Codex F1 — OUTSIDE .rea/)
|
|
94
|
+
*/
|
|
95
|
+
export declare const REA_GITIGNORE_ENTRIES: readonly string[];
|
|
96
|
+
export interface EnsureGitignoreResult {
|
|
97
|
+
/** Absolute path to the `.gitignore` file that was (maybe) written. */
|
|
98
|
+
path: string;
|
|
99
|
+
/** `created` = no file before. `updated` = block added or amended. `unchanged` = no-op. */
|
|
100
|
+
action: 'created' | 'updated' | 'unchanged';
|
|
101
|
+
/** Entries the caller added this run (subset of `REA_GITIGNORE_ENTRIES`). */
|
|
102
|
+
addedEntries: string[];
|
|
103
|
+
/** Non-fatal operator-facing messages (e.g. symlink refused, duplicate blocks). */
|
|
104
|
+
warnings: string[];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Main entry point. Idempotent: calling twice in a row produces `unchanged`
|
|
108
|
+
* on the second call.
|
|
109
|
+
*
|
|
110
|
+
* The `entries` parameter defaults to `REA_GITIGNORE_ENTRIES` — both `rea
|
|
111
|
+
* init` and `rea upgrade` pass the default. Tests override to verify
|
|
112
|
+
* reconciliation.
|
|
113
|
+
*/
|
|
114
|
+
export declare function ensureReaGitignore(targetDir: string, entries?: readonly string[]): Promise<EnsureGitignoreResult>;
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BUG-010 — `.gitignore` scaffolding for rea-managed runtime artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Background. `rea serve` (G7 catalog fingerprint) writes
|
|
5
|
+
* `.rea/fingerprints.json` at startup. `rea init` in 0.4.0 and earlier never
|
|
6
|
+
* scaffolded ANY `.gitignore` entries for the consumer repo, so an operator
|
|
7
|
+
* who ran `rea init` then started the gateway would see a "new file" in
|
|
8
|
+
* `git status` that nobody told them about. Helix reported this as BUG-010.
|
|
9
|
+
*
|
|
10
|
+
* The fix is broader than fingerprints.json — every runtime artifact rea
|
|
11
|
+
* writes (under `.rea/` AND its sibling `proper-lockfile` directory at
|
|
12
|
+
* `.rea.lock`) must be in the consumer's `.gitignore`:
|
|
13
|
+
*
|
|
14
|
+
* - `.rea/audit.jsonl` — G1 hash-chained audit log (append-only)
|
|
15
|
+
* - `.rea/audit-*.jsonl` — G1 rotated audit archives
|
|
16
|
+
* - `.rea/HALT` — /freeze marker (ephemeral)
|
|
17
|
+
* - `.rea/metrics.jsonl` — G5 metrics stream
|
|
18
|
+
* - `.rea/serve.pid` — G5 `rea serve` pidfile
|
|
19
|
+
* - `.rea/serve.state.json` — G5 `rea serve` state snapshot
|
|
20
|
+
* - `.rea/fingerprints.json` — G7 downstream catalog fingerprints (BUG-010)
|
|
21
|
+
* - `.rea/review-cache.jsonl` — BUG-009 review cache (rea cache set/check)
|
|
22
|
+
* - `.rea/*.tmp` — serve temp-file-then-rename pattern
|
|
23
|
+
* - `.rea/*.tmp.*` — review-cache pid-salted temp pattern
|
|
24
|
+
* - `.rea/install-manifest.json.bak` / `.tmp` — fs-safe atomic-replace sidecars
|
|
25
|
+
* - `.gitignore.rea-tmp-*` — this module's own temp files on crash
|
|
26
|
+
* (root-level — writeAtomic stages next
|
|
27
|
+
* to .gitignore, not under .rea/)
|
|
28
|
+
* - `.rea.lock` — proper-lockfile sibling dir (NOT under .rea/)
|
|
29
|
+
* (Codex F1 on the BUG-010 review caught all three of these last groups.)
|
|
30
|
+
*
|
|
31
|
+
* Idempotency contract.
|
|
32
|
+
*
|
|
33
|
+
* - `rea init` on a fresh repo with no `.gitignore` → create one with the
|
|
34
|
+
* managed block only.
|
|
35
|
+
* - `rea init` on a repo with a `.gitignore` that has NO rea block → append
|
|
36
|
+
* a managed block separated by a blank line.
|
|
37
|
+
* - `rea upgrade` on an older install whose `.gitignore` lacks the block →
|
|
38
|
+
* same as init; backfill the block so `fingerprints.json` stops showing
|
|
39
|
+
* up as an untracked file.
|
|
40
|
+
* - `rea upgrade` where the managed block exists but is missing some new
|
|
41
|
+
* entries (e.g. `fingerprints.json`, `review-cache.jsonl` added in 0.5.0)
|
|
42
|
+
* → insert the missing lines inside the existing block, preserving any
|
|
43
|
+
* operator-authored lines within the block.
|
|
44
|
+
* - All entries already present, in any order → no-op.
|
|
45
|
+
*
|
|
46
|
+
* Operator DELETIONS of canonical entries are NOT preserved — re-running
|
|
47
|
+
* ensureReaGitignore will re-insert any canonical entry missing from the
|
|
48
|
+
* block body. To opt out of ignoring a specific artifact, operators must
|
|
49
|
+
* configure rea itself, not edit the managed block. This is intentional —
|
|
50
|
+
* the managed block is rea's territory.
|
|
51
|
+
*
|
|
52
|
+
* Security/containment.
|
|
53
|
+
*
|
|
54
|
+
* - Refuse to follow a `.gitignore` symlink (`lstat` gate before any read).
|
|
55
|
+
* The subsequent read uses `O_NOFOLLOW | O_RDONLY` so a TOCTOU swap after
|
|
56
|
+
* the lstat cannot trick us into reading through a symlink to secrets
|
|
57
|
+
* (e.g. `~/.ssh/id_rsa`) and splicing them into the written `.gitignore`.
|
|
58
|
+
* - Temp file name uses `crypto.randomBytes(16)` — not PID + Date.now, which
|
|
59
|
+
* are predictable and leak process info. (Codex F2.)
|
|
60
|
+
* - Cleanup best-effort on write failure so a stale temp file from a
|
|
61
|
+
* prior crash does not accrete.
|
|
62
|
+
*
|
|
63
|
+
* CRLF compatibility (Codex F3).
|
|
64
|
+
*
|
|
65
|
+
* Windows consumers with `core.autocrlf=true` get CRLF line endings on
|
|
66
|
+
* `.gitignore`. Without explicit handling, `"# === rea managed ==="` !==
|
|
67
|
+
* `"# === rea managed ===\r"` and every upgrade would append a duplicate
|
|
68
|
+
* block. We detect the input EOL on read, split on `\r?\n`, trim trailing
|
|
69
|
+
* whitespace from each line before marker-anchored matching, and re-emit
|
|
70
|
+
* with the detected EOL on write.
|
|
71
|
+
*
|
|
72
|
+
* Duplicate blocks (Codex F4).
|
|
73
|
+
*
|
|
74
|
+
* If the file already contains two managed blocks (from a prior bug,
|
|
75
|
+
* manual copy-paste, or two different rea versions), refuse to modify and
|
|
76
|
+
* surface a warning. Merging is more ambitious than this module needs to
|
|
77
|
+
* be — the operator resolves manually, then a subsequent run proceeds.
|
|
78
|
+
*/
|
|
79
|
+
import crypto from 'node:crypto';
|
|
80
|
+
import fsPromises from 'node:fs/promises';
|
|
81
|
+
import path from 'node:path';
|
|
82
|
+
const GITIGNORE = '.gitignore';
|
|
83
|
+
export const GITIGNORE_BLOCK_START = '# === rea managed — do not edit between markers ===';
|
|
84
|
+
export const GITIGNORE_BLOCK_END = '# === end rea managed ===';
|
|
85
|
+
/**
|
|
86
|
+
* Ordered list of entries every rea install must gitignore. Order is stable
|
|
87
|
+
* so the scaffolded block is deterministic across runs, which in turn makes
|
|
88
|
+
* drift detection tractable: a diff in the managed block means a consumer
|
|
89
|
+
* (or another installer) edited it, not that rea reshuffled.
|
|
90
|
+
*
|
|
91
|
+
* The grouping below is by origin, not alphabetical:
|
|
92
|
+
* 1. audit + HALT + metrics (G1, G4, G5)
|
|
93
|
+
* 2. serve state (G5)
|
|
94
|
+
* 3. fingerprints (G7 / BUG-010)
|
|
95
|
+
* 4. review cache (BUG-009)
|
|
96
|
+
* 5. temp/sidecar patterns (Codex F1)
|
|
97
|
+
* 6. sibling lockfile (Codex F1 — OUTSIDE .rea/)
|
|
98
|
+
*/
|
|
99
|
+
export const REA_GITIGNORE_ENTRIES = [
|
|
100
|
+
'.rea/audit.jsonl',
|
|
101
|
+
'.rea/audit-*.jsonl',
|
|
102
|
+
'.rea/HALT',
|
|
103
|
+
'.rea/metrics.jsonl',
|
|
104
|
+
'.rea/serve.pid',
|
|
105
|
+
'.rea/serve.state.json',
|
|
106
|
+
'.rea/fingerprints.json',
|
|
107
|
+
'.rea/review-cache.jsonl',
|
|
108
|
+
'.rea/*.tmp',
|
|
109
|
+
'.rea/*.tmp.*',
|
|
110
|
+
'.rea/install-manifest.json.bak',
|
|
111
|
+
'.rea/install-manifest.json.tmp',
|
|
112
|
+
// This module's own crash-time temp files. `writeAtomic` stages the temp
|
|
113
|
+
// next to `.gitignore` (i.e. at the repo root), NOT under `.rea/` — so
|
|
114
|
+
// the glob must live at the repo root too. Codex F2 on the re-review
|
|
115
|
+
// caught the earlier `.rea/.gitignore.rea-tmp-*` mismatch.
|
|
116
|
+
'.gitignore.rea-tmp-*',
|
|
117
|
+
// proper-lockfile (audit chain, cache) locks `.rea/` via a SIBLING dir at
|
|
118
|
+
// `.rea.lock` — NOT inside `.rea/`. If this looks wrong to a future
|
|
119
|
+
// maintainer: it is correct, see src/audit/fs.ts.
|
|
120
|
+
'.rea.lock',
|
|
121
|
+
];
|
|
122
|
+
function buildManagedBlock(entries, eol) {
|
|
123
|
+
return [GITIGNORE_BLOCK_START, ...entries, GITIGNORE_BLOCK_END].join(eol);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Trim trailing whitespace ONLY (not leading) and strip a leading UTF-8 BOM.
|
|
127
|
+
* Leading whitespace would defeat the substring-spoof-rejection guarantee
|
|
128
|
+
* the tests exercise (`## === rea managed ===` must NOT match).
|
|
129
|
+
*/
|
|
130
|
+
function normalizeLineForMatch(line, isFirst) {
|
|
131
|
+
const noBom = isFirst ? line.replace(/^\uFEFF/, '') : line;
|
|
132
|
+
return noBom.replace(/\s+$/, '');
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Find the managed block by ANCHORED marker lines — substring matches are
|
|
136
|
+
* rejected. A consumer comment containing the sentinel string must not
|
|
137
|
+
* reclassify an arbitrary block as rea-managed.
|
|
138
|
+
*
|
|
139
|
+
* Returns `null` if the start or end marker is not present, or if the start
|
|
140
|
+
* appears after the end (mangled block — caller falls back to append).
|
|
141
|
+
*
|
|
142
|
+
* Returns `'duplicate'` if more than one start marker or more than one end
|
|
143
|
+
* marker is found — caller refuses to modify in that case.
|
|
144
|
+
*/
|
|
145
|
+
function findManagedBlock(lines) {
|
|
146
|
+
const startIndices = [];
|
|
147
|
+
const endIndices = [];
|
|
148
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
149
|
+
const norm = normalizeLineForMatch(lines[i], i === 0);
|
|
150
|
+
if (norm === GITIGNORE_BLOCK_START)
|
|
151
|
+
startIndices.push(i);
|
|
152
|
+
else if (norm === GITIGNORE_BLOCK_END)
|
|
153
|
+
endIndices.push(i);
|
|
154
|
+
}
|
|
155
|
+
if (startIndices.length === 0 || endIndices.length === 0)
|
|
156
|
+
return null;
|
|
157
|
+
if (startIndices.length > 1 || endIndices.length > 1)
|
|
158
|
+
return 'duplicate';
|
|
159
|
+
const [startIdx] = startIndices;
|
|
160
|
+
const [endIdx] = endIndices;
|
|
161
|
+
if (endIdx <= startIdx)
|
|
162
|
+
return null;
|
|
163
|
+
return { startIdx, endIdx };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Ensure every required entry is present in the managed block. Preserves any
|
|
167
|
+
* operator-authored lines between the markers (e.g. a consumer adds
|
|
168
|
+
* `.rea/my-local-cache` to the block directly — we leave it alone). Missing
|
|
169
|
+
* required entries are appended in the canonical order, after the existing
|
|
170
|
+
* body lines.
|
|
171
|
+
*
|
|
172
|
+
* NOTE: operator deletions of canonical entries are NOT preserved — see the
|
|
173
|
+
* module docstring.
|
|
174
|
+
*/
|
|
175
|
+
function reconcileBlock(bodyLines, required) {
|
|
176
|
+
const present = new Set(bodyLines.map((l) => l.replace(/\s+$/, '')).filter((l) => l.length > 0));
|
|
177
|
+
const added = [];
|
|
178
|
+
const appended = [];
|
|
179
|
+
for (const entry of required) {
|
|
180
|
+
if (!present.has(entry)) {
|
|
181
|
+
appended.push(entry);
|
|
182
|
+
added.push(entry);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { lines: [...bodyLines, ...appended], added };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Open `.gitignore` via `O_NOFOLLOW | O_RDONLY` so a symlink that appeared
|
|
189
|
+
* after our `lstat` (TOCTOU window) cannot be followed. Darwin/Linux map
|
|
190
|
+
* `O_NOFOLLOW` to `ELOOP`; we translate that to the same symlink-refusal
|
|
191
|
+
* message the lstat path would produce.
|
|
192
|
+
*
|
|
193
|
+
* Returns `null` when the file does not exist.
|
|
194
|
+
*/
|
|
195
|
+
async function readGitignoreIfFile(absPath) {
|
|
196
|
+
let lst;
|
|
197
|
+
try {
|
|
198
|
+
lst = await fsPromises.lstat(absPath);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (err.code === 'ENOENT')
|
|
202
|
+
return null;
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
if (lst.isSymbolicLink()) {
|
|
206
|
+
throw new Error(`${absPath} is a symlink — refusing to edit .gitignore through a link. ` +
|
|
207
|
+
`Replace the link with a regular file and rerun.`);
|
|
208
|
+
}
|
|
209
|
+
if (!lst.isFile()) {
|
|
210
|
+
throw new Error(`${absPath} is not a regular file (type=${String(lst.mode & 0o170000)}) — refusing to edit.`);
|
|
211
|
+
}
|
|
212
|
+
// O_NOFOLLOW closes the TOCTOU window between lstat and open on POSIX.
|
|
213
|
+
// On Windows O_NOFOLLOW is not defined — refuse to edit an existing
|
|
214
|
+
// `.gitignore` there rather than silently accept the TOCTOU hole.
|
|
215
|
+
// (Codex F1 on the bc2b77b re-review.) Consumers who still have a
|
|
216
|
+
// regular file get the lstat-only protection below; operators who end
|
|
217
|
+
// up with a symlinked .gitignore get a refusal rather than a splice.
|
|
218
|
+
const O_NOFOLLOW = fsPromises.constants?.O_NOFOLLOW;
|
|
219
|
+
const O_RDONLY = fsPromises.constants?.O_RDONLY;
|
|
220
|
+
if (O_NOFOLLOW === undefined || O_RDONLY === undefined) {
|
|
221
|
+
throw new Error(`${absPath} exists and this platform lacks O_NOFOLLOW — refusing to edit ` +
|
|
222
|
+
`an existing .gitignore without symlink-race protection. Delete the ` +
|
|
223
|
+
`file first if rea should scaffold a fresh one.`);
|
|
224
|
+
}
|
|
225
|
+
const fd = await fsPromises
|
|
226
|
+
.open(absPath, O_RDONLY | O_NOFOLLOW)
|
|
227
|
+
.catch((err) => {
|
|
228
|
+
if (err.code === 'ELOOP') {
|
|
229
|
+
throw new Error(`${absPath} became a symlink between lstat and open — refusing to read.`);
|
|
230
|
+
}
|
|
231
|
+
throw err;
|
|
232
|
+
});
|
|
233
|
+
try {
|
|
234
|
+
return await fd.readFile('utf8');
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
await fd.close();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Write `.gitignore` with a temp-file + rename, same pattern as the cache
|
|
242
|
+
* atomic-clear (F4). Avoids torn reads for any tool (IDE, `rea doctor`)
|
|
243
|
+
* racing this write.
|
|
244
|
+
*
|
|
245
|
+
* Temp-name uses `crypto.randomBytes(16)` (not PID/timestamp) — Codex F2
|
|
246
|
+
* flagged the old name as predictable, which gave a local attacker a way
|
|
247
|
+
* to pre-create the path and block the write (or place a FIFO on it).
|
|
248
|
+
*/
|
|
249
|
+
async function writeAtomic(absPath, content) {
|
|
250
|
+
const dir = path.dirname(absPath);
|
|
251
|
+
const rand = crypto.randomBytes(16).toString('hex');
|
|
252
|
+
const tmp = path.join(dir, `.gitignore.rea-tmp-${rand}`);
|
|
253
|
+
try {
|
|
254
|
+
await fsPromises.writeFile(tmp, content, { encoding: 'utf8', mode: 0o644 });
|
|
255
|
+
await fsPromises.rename(tmp, absPath);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
await fsPromises.unlink(tmp).catch(() => {
|
|
259
|
+
// Best-effort cleanup. If rename failed the tmp exists; if writeFile
|
|
260
|
+
// failed before anything landed, unlink fails with ENOENT — either way
|
|
261
|
+
// we don't want the original error masked.
|
|
262
|
+
});
|
|
263
|
+
throw err;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Main entry point. Idempotent: calling twice in a row produces `unchanged`
|
|
268
|
+
* on the second call.
|
|
269
|
+
*
|
|
270
|
+
* The `entries` parameter defaults to `REA_GITIGNORE_ENTRIES` — both `rea
|
|
271
|
+
* init` and `rea upgrade` pass the default. Tests override to verify
|
|
272
|
+
* reconciliation.
|
|
273
|
+
*/
|
|
274
|
+
export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTRIES) {
|
|
275
|
+
const absPath = path.resolve(targetDir, GITIGNORE);
|
|
276
|
+
const warnings = [];
|
|
277
|
+
let existing;
|
|
278
|
+
try {
|
|
279
|
+
existing = await readGitignoreIfFile(absPath);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
warnings.push(err.message);
|
|
283
|
+
return { path: absPath, action: 'unchanged', addedEntries: [], warnings };
|
|
284
|
+
}
|
|
285
|
+
// Detect EOL so a CRLF repo stays CRLF and doesn't get torn. Codex F3.
|
|
286
|
+
const eol = existing !== null && existing.includes('\r\n') ? '\r\n' : '\n';
|
|
287
|
+
if (existing === null) {
|
|
288
|
+
const content = buildManagedBlock(entries, '\n') + '\n';
|
|
289
|
+
await writeAtomic(absPath, content);
|
|
290
|
+
return {
|
|
291
|
+
path: absPath,
|
|
292
|
+
action: 'created',
|
|
293
|
+
addedEntries: [...entries],
|
|
294
|
+
warnings,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const lines = existing.split(/\r?\n/);
|
|
298
|
+
const hadTrailingNewline = existing.endsWith('\n');
|
|
299
|
+
const block = findManagedBlock(lines);
|
|
300
|
+
if (block === 'duplicate') {
|
|
301
|
+
warnings.push(`${absPath} contains multiple '# === rea managed' blocks — refusing to modify. ` +
|
|
302
|
+
`Consolidate the managed blocks manually and rerun.`);
|
|
303
|
+
return { path: absPath, action: 'unchanged', addedEntries: [], warnings };
|
|
304
|
+
}
|
|
305
|
+
if (block === null) {
|
|
306
|
+
// No managed block. Append one after a blank-line separator (unless the
|
|
307
|
+
// file is empty or already ends with a blank line).
|
|
308
|
+
const trimmedTailIdx = (() => {
|
|
309
|
+
let i = lines.length - 1;
|
|
310
|
+
while (i >= 0 && lines[i] === '')
|
|
311
|
+
i -= 1;
|
|
312
|
+
return i;
|
|
313
|
+
})();
|
|
314
|
+
const bodyLines = lines.slice(0, trimmedTailIdx + 1);
|
|
315
|
+
const separator = bodyLines.length === 0 ? [] : [''];
|
|
316
|
+
const newLines = [
|
|
317
|
+
...bodyLines,
|
|
318
|
+
...separator,
|
|
319
|
+
buildManagedBlock(entries, eol),
|
|
320
|
+
];
|
|
321
|
+
const content = newLines.join(eol) + eol;
|
|
322
|
+
await writeAtomic(absPath, content);
|
|
323
|
+
return {
|
|
324
|
+
path: absPath,
|
|
325
|
+
action: 'updated',
|
|
326
|
+
addedEntries: [...entries],
|
|
327
|
+
warnings,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
// Managed block exists — reconcile body lines.
|
|
331
|
+
const bodyLines = lines.slice(block.startIdx + 1, block.endIdx);
|
|
332
|
+
const { lines: reconciledBody, added } = reconcileBlock(bodyLines, entries);
|
|
333
|
+
if (added.length === 0) {
|
|
334
|
+
return {
|
|
335
|
+
path: absPath,
|
|
336
|
+
action: 'unchanged',
|
|
337
|
+
addedEntries: [],
|
|
338
|
+
warnings,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const newLines = [
|
|
342
|
+
...lines.slice(0, block.startIdx + 1),
|
|
343
|
+
...reconciledBody,
|
|
344
|
+
...lines.slice(block.endIdx),
|
|
345
|
+
];
|
|
346
|
+
let content = newLines.join(eol);
|
|
347
|
+
if (hadTrailingNewline && !content.endsWith(eol))
|
|
348
|
+
content += eol;
|
|
349
|
+
await writeAtomic(absPath, content);
|
|
350
|
+
return {
|
|
351
|
+
path: absPath,
|
|
352
|
+
action: 'updated',
|
|
353
|
+
addedEntries: added,
|
|
354
|
+
warnings,
|
|
355
|
+
};
|
|
356
|
+
}
|
package/dist/cli/upgrade.js
CHANGED
|
@@ -45,6 +45,7 @@ import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFile
|
|
|
45
45
|
import { buildFragment, extractFragment, } from './install/claude-md.js';
|
|
46
46
|
import { atomicReplaceFile, safeDeleteFile, safeInstallFile, safeReadFile, } from './install/fs-safe.js';
|
|
47
47
|
import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
|
|
48
|
+
import { ensureReaGitignore } from './install/gitignore.js';
|
|
48
49
|
import { manifestExists, readManifest, writeManifestAtomic, } from './install/manifest-io.js';
|
|
49
50
|
import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
|
|
50
51
|
import { err, getPkgVersion, log, warn } from './utils.js';
|
|
@@ -475,6 +476,25 @@ export async function runUpgrade(options = {}) {
|
|
|
475
476
|
source: 'claude-md',
|
|
476
477
|
});
|
|
477
478
|
}
|
|
479
|
+
// BUG-010 — ensure `.gitignore` carries every runtime artifact entry. This
|
|
480
|
+
// backfills older installs that predate the scaffolding in `rea init`. A
|
|
481
|
+
// consumer who upgraded from 0.3.x/0.4.0 was previously seeing
|
|
482
|
+
// `.rea/fingerprints.json` show up as an untracked file; this closes the
|
|
483
|
+
// loop without touching operator-authored gitignore lines.
|
|
484
|
+
if (!dryRun) {
|
|
485
|
+
const gi = await ensureReaGitignore(resolvedRoot);
|
|
486
|
+
if (gi.action === 'created') {
|
|
487
|
+
console.log(` + ${path.relative(resolvedRoot, gi.path)} (managed block written)`);
|
|
488
|
+
}
|
|
489
|
+
else if (gi.action === 'updated') {
|
|
490
|
+
console.log(` ~ ${path.relative(resolvedRoot, gi.path)} (managed block ${gi.addedEntries.length} entr${gi.addedEntries.length === 1 ? 'y' : 'ies'} added)`);
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
console.log(` · ${path.relative(resolvedRoot, gi.path)} (managed block up to date)`);
|
|
494
|
+
}
|
|
495
|
+
for (const w of gi.warnings)
|
|
496
|
+
warn(w);
|
|
497
|
+
}
|
|
478
498
|
if (dryRun) {
|
|
479
499
|
console.log('');
|
|
480
500
|
log('dry run — no changes written.');
|
|
@@ -14,8 +14,36 @@ export interface PrefixedTool extends DownstreamToolInfo {
|
|
|
14
14
|
/** Full prefixed name, as exposed to the upstream client. */
|
|
15
15
|
name: string;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Per-downstream state surfaced by the `__rea__health` meta-tool. Kept
|
|
19
|
+
* separate from the richer internal state so we only expose what a caller
|
|
20
|
+
* can actually reason about.
|
|
21
|
+
*/
|
|
22
|
+
export interface DownstreamHealth {
|
|
23
|
+
name: string;
|
|
24
|
+
/** Registered in the registry (always true for entries present in the pool). */
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
/** Underlying MCP client currently connected. */
|
|
27
|
+
connected: boolean;
|
|
28
|
+
/** Gateway considers this downstream healthy enough to route calls to. */
|
|
29
|
+
healthy: boolean;
|
|
30
|
+
/** Last error observed, or null if the connection is clean or never errored. */
|
|
31
|
+
last_error: string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Number of tools advertised by the downstream on the most recent
|
|
34
|
+
* successful `tools/list`, or null when never listed / listing failed.
|
|
35
|
+
*/
|
|
36
|
+
tools_count: number | null;
|
|
37
|
+
}
|
|
17
38
|
export declare class DownstreamPool {
|
|
18
39
|
private readonly connections;
|
|
40
|
+
/**
|
|
41
|
+
* Cached tool counts from the most recent successful `listAllTools` cycle,
|
|
42
|
+
* keyed by server name. Surfaced via `healthSnapshot()` so the meta-tool
|
|
43
|
+
* can report per-server counts even when the current listing pass fails
|
|
44
|
+
* or is skipped. Stale but truthful > absent.
|
|
45
|
+
*/
|
|
46
|
+
private readonly lastToolsCount;
|
|
19
47
|
constructor(registry: Registry, logger?: Logger);
|
|
20
48
|
get size(): number;
|
|
21
49
|
connectAll(): Promise<void>;
|
|
@@ -25,6 +53,12 @@ export declare class DownstreamPool {
|
|
|
25
53
|
* will see a smaller catalog rather than a crash.
|
|
26
54
|
*/
|
|
27
55
|
listAllTools(): Promise<PrefixedTool[]>;
|
|
56
|
+
/**
|
|
57
|
+
* Snapshot per-server connection state for the `__rea__health` meta-tool.
|
|
58
|
+
* Pure / non-blocking — no MCP I/O — so it can be called while HALT is
|
|
59
|
+
* active or while other tool calls are in-flight.
|
|
60
|
+
*/
|
|
61
|
+
healthSnapshot(): DownstreamHealth[];
|
|
28
62
|
/**
|
|
29
63
|
* Split a prefixed tool name and dispatch. Returns the raw result from the
|
|
30
64
|
* downstream (the gateway response handler shapes it for the upstream reply).
|