moflo 4.9.5 → 4.9.7
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/bin/index-all.mjs +42 -4
- package/bin/lib/index-fingerprint.mjs +162 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +5 -4
- package/scripts/post-install-bootstrap.mjs +291 -0
package/bin/index-all.mjs
CHANGED
|
@@ -15,6 +15,16 @@ import { fileURLToPath } from 'url';
|
|
|
15
15
|
import { spawn, spawnSync } from 'child_process';
|
|
16
16
|
import { platform } from 'os';
|
|
17
17
|
import { hnswIndexPath } from './lib/moflo-paths.mjs';
|
|
18
|
+
import { decideIndexGate, saveFingerprint } from './lib/index-fingerprint.mjs';
|
|
19
|
+
|
|
20
|
+
// Cap fastembed/ONNX thread count when spawning the heavy steps. Without
|
|
21
|
+
// this, ONNX defaults to one thread per CPU core (22+ on a modern dev box),
|
|
22
|
+
// pegging the entire machine while the indexer runs. 2 threads keeps
|
|
23
|
+
// re-embedding throughput acceptable while leaving the box usable.
|
|
24
|
+
const ONNX_THREAD_CAP = {
|
|
25
|
+
OMP_NUM_THREADS: '2',
|
|
26
|
+
ONNXRUNTIME_INTRA_OP_NUM_THREADS: '2',
|
|
27
|
+
};
|
|
18
28
|
|
|
19
29
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
30
|
|
|
@@ -110,7 +120,7 @@ function killProcessTree(child) {
|
|
|
110
120
|
}
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
function runStep(label, cmd, args, timeoutMs = 120_000) {
|
|
123
|
+
function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
|
|
114
124
|
return new Promise((resolveStep) => {
|
|
115
125
|
const start = Date.now();
|
|
116
126
|
log(`START ${label}`);
|
|
@@ -119,6 +129,7 @@ function runStep(label, cmd, args, timeoutMs = 120_000) {
|
|
|
119
129
|
stdio: 'ignore',
|
|
120
130
|
windowsHide: true,
|
|
121
131
|
detached: platform() !== 'win32', // POSIX: own process group for tree-kill
|
|
132
|
+
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
|
|
122
133
|
});
|
|
123
134
|
let timedOut = false;
|
|
124
135
|
const timer = setTimeout(() => {
|
|
@@ -150,7 +161,19 @@ function runStep(label, cmd, args, timeoutMs = 120_000) {
|
|
|
150
161
|
|
|
151
162
|
async function main() {
|
|
152
163
|
const startTime = Date.now();
|
|
153
|
-
|
|
164
|
+
|
|
165
|
+
// ── Fingerprint gate ─────────────────────────────────────────────────────
|
|
166
|
+
// Skip the entire chain when none of {memory.db, moflo pkg, moflo.yaml,
|
|
167
|
+
// .claude/guidance/} have changed since the last successful run. Without
|
|
168
|
+
// this gate the chain re-embeds + rebuilds HNSW on every Claude session-
|
|
169
|
+
// start even when nothing changed — the customer-visible CPU peg this
|
|
170
|
+
// module exists to fix. Override with FLO_FORCE_INDEX=1.
|
|
171
|
+
const gate = decideIndexGate(projectRoot);
|
|
172
|
+
if (gate.skip) {
|
|
173
|
+
log(`SKIP full chain — ${gate.reason} (no inputs changed since last run)`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
log(`Sequential indexing chain started (gate: ${gate.reason})`);
|
|
154
177
|
|
|
155
178
|
// 1. Guidance indexer
|
|
156
179
|
if (isIndexEnabled('guidance')) {
|
|
@@ -211,9 +234,10 @@ async function main() {
|
|
|
211
234
|
// 6. Build embeddings — single pass for ALL namespaces, after all indexers finish.
|
|
212
235
|
// Individual indexers are called with --no-embeddings to prevent background
|
|
213
236
|
// embedding spawns that race with this chain (sql.js last-write-wins).
|
|
237
|
+
// Thread-capped: fastembed/ONNX would otherwise pin every CPU core.
|
|
214
238
|
const embeddingsScript = resolveBin('flo-embeddings', 'build-embeddings.mjs');
|
|
215
239
|
if (embeddingsScript) {
|
|
216
|
-
await runStep('build-embeddings', 'node', [embeddingsScript], 300_000);
|
|
240
|
+
await runStep('build-embeddings', 'node', [embeddingsScript], 300_000, ONNX_THREAD_CAP);
|
|
217
241
|
} else {
|
|
218
242
|
log('SKIP build-embeddings (script not found)');
|
|
219
243
|
}
|
|
@@ -222,9 +246,10 @@ async function main() {
|
|
|
222
246
|
// rebuild-index now also writes the binary HNSW sidecar at
|
|
223
247
|
// .moflo/hnsw.index, which can take longer than the default 120s on a
|
|
224
248
|
// populated consumer DB — match build-embeddings' 300s budget.
|
|
249
|
+
// Thread-capped: same fastembed CPU-peg risk as build-embeddings.
|
|
225
250
|
let hnswOk = true;
|
|
226
251
|
if (localCli) {
|
|
227
|
-
const ok = await runStep('hnsw-rebuild', 'node', [localCli, 'memory', 'rebuild-index', '--force'], 300_000);
|
|
252
|
+
const ok = await runStep('hnsw-rebuild', 'node', [localCli, 'memory', 'rebuild-index', '--force'], 300_000, ONNX_THREAD_CAP);
|
|
228
253
|
if (ok) {
|
|
229
254
|
const sidecar = hnswIndexPath(projectRoot);
|
|
230
255
|
if (!existsSync(sidecar)) {
|
|
@@ -243,6 +268,19 @@ async function main() {
|
|
|
243
268
|
|
|
244
269
|
const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
245
270
|
log(`Sequential indexing chain complete (${totalElapsed}s)`);
|
|
271
|
+
|
|
272
|
+
// Save the fingerprint AFTER a successful chain run. If hnsw failed, we
|
|
273
|
+
// intentionally don't save — the next session-start will retry. Save
|
|
274
|
+
// failures are non-fatal (next run will recompute and just re-run the
|
|
275
|
+
// chain, no correctness hazard).
|
|
276
|
+
if (hnswOk) {
|
|
277
|
+
if (saveFingerprint(projectRoot, gate.current)) {
|
|
278
|
+
log('Saved index-all fingerprint for next-session gate');
|
|
279
|
+
} else {
|
|
280
|
+
log('WARN fingerprint save failed (next session will re-run chain)');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
246
284
|
if (!hnswOk) process.exit(1);
|
|
247
285
|
}
|
|
248
286
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fingerprint gate for the session-start indexer chain.
|
|
3
|
+
*
|
|
4
|
+
* The chain `index-all.mjs` runs on every Claude Code session-start. Without
|
|
5
|
+
* a gate it does full indexing + embedding + HNSW work even when nothing has
|
|
6
|
+
* meaningfully changed — pegging the box for several minutes per session.
|
|
7
|
+
*
|
|
8
|
+
* This module computes a small "did anything that matters change" fingerprint
|
|
9
|
+
* and persists it after each successful run. The chain skips entirely when
|
|
10
|
+
* the current fingerprint matches the saved one.
|
|
11
|
+
*
|
|
12
|
+
* Inputs we consider "changes that warrant re-indexing":
|
|
13
|
+
* - `.moflo/moflo.db` mtime → memory writes (new entries, deletes)
|
|
14
|
+
* - `node_modules/moflo/package.json` mtime → moflo upgrade
|
|
15
|
+
* - `moflo.yaml` mtime → config change (auto_index toggles)
|
|
16
|
+
* - `.claude/guidance/**` recursive newest-mtime → shipped or local guidance
|
|
17
|
+
* content changed (the launcher rewrites these on upgrade)
|
|
18
|
+
*
|
|
19
|
+
* Inputs we DON'T consider:
|
|
20
|
+
* - Source files (src/, app/, lib/, etc.) — individual indexers already
|
|
21
|
+
* have incremental detection, so re-running the chain on a source edit
|
|
22
|
+
* would be redundant. If a consumer reports stale codemap on edits,
|
|
23
|
+
* extend the fingerprint in a follow-up. Keeping the set small makes
|
|
24
|
+
* the gate cheap (no broad project tree walks at session-start).
|
|
25
|
+
*
|
|
26
|
+
* Override: set `FLO_FORCE_INDEX=1` in the environment to bypass the gate
|
|
27
|
+
* (useful for `flo doctor --fix` or manual rebuilds).
|
|
28
|
+
*
|
|
29
|
+
* Failure posture:
|
|
30
|
+
* - Errors reading the fingerprint or computing inputs → return null,
|
|
31
|
+
* which forces the chain to run (safe fallback).
|
|
32
|
+
* - Errors saving the fingerprint → don't fail the run; the next run will
|
|
33
|
+
* also recompute and run, no correctness hazard.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { existsSync, readFileSync, writeFileSync, statSync, readdirSync } from 'node:fs';
|
|
37
|
+
import { join, resolve } from 'node:path';
|
|
38
|
+
|
|
39
|
+
export const FINGERPRINT_FILE_REL = '.moflo/index-all-fingerprint.json';
|
|
40
|
+
export const FINGERPRINT_VERSION = 1;
|
|
41
|
+
export const FORCE_ENV = 'FLO_FORCE_INDEX';
|
|
42
|
+
|
|
43
|
+
function safeMtime(path) {
|
|
44
|
+
try { return statSync(path).mtimeMs; } catch { return 0; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Newest mtime across all files under `dir`, recursive. Skips dot-dirs and
|
|
49
|
+
* `node_modules` to avoid walking irrelevant trees.
|
|
50
|
+
*
|
|
51
|
+
* Capped depth keeps the cost bounded (.claude/guidance/ has ~2 levels in
|
|
52
|
+
* the wild; cap at 6 for safety).
|
|
53
|
+
*/
|
|
54
|
+
function newestMtimeRecursive(dir, depth = 6) {
|
|
55
|
+
if (depth <= 0) return 0;
|
|
56
|
+
let max = 0;
|
|
57
|
+
try {
|
|
58
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
59
|
+
if (entry.name.startsWith('.') && entry.name !== '.') continue;
|
|
60
|
+
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
|
|
61
|
+
const full = join(dir, entry.name);
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
const m = newestMtimeRecursive(full, depth - 1);
|
|
64
|
+
if (m > max) max = m;
|
|
65
|
+
} else if (entry.isFile()) {
|
|
66
|
+
const m = safeMtime(full);
|
|
67
|
+
if (m > max) max = m;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Unreadable dir — return 0; caller will compare equality and fall through.
|
|
72
|
+
}
|
|
73
|
+
return max;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compute the current fingerprint for `projectRoot`. Cheap (a handful of
|
|
78
|
+
* stat() calls + one bounded recursive walk over .claude/guidance/).
|
|
79
|
+
*
|
|
80
|
+
* Returns a flat object of { input → mtimeMs }. Missing files contribute 0,
|
|
81
|
+
* which still produces a stable fingerprint as long as their absence is
|
|
82
|
+
* stable.
|
|
83
|
+
*/
|
|
84
|
+
export function computeFingerprint(projectRoot) {
|
|
85
|
+
return {
|
|
86
|
+
memoryDb: safeMtime(resolve(projectRoot, '.moflo/moflo.db')),
|
|
87
|
+
mofloPkg: safeMtime(resolve(projectRoot, 'node_modules/moflo/package.json')),
|
|
88
|
+
mofloYaml: safeMtime(resolve(projectRoot, 'moflo.yaml')),
|
|
89
|
+
guidance: newestMtimeRecursive(resolve(projectRoot, '.claude/guidance')),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compare two fingerprints by exact equality on every recorded key. Either
|
|
95
|
+
* side being null/undefined returns false — forces a run when the saved
|
|
96
|
+
* record is missing or unparseable.
|
|
97
|
+
*/
|
|
98
|
+
export function fingerprintsEqual(a, b) {
|
|
99
|
+
if (!a || !b) return false;
|
|
100
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
101
|
+
for (const k of keys) {
|
|
102
|
+
if (a[k] !== b[k]) return false;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read the saved fingerprint payload from disk. Returns the bare fingerprint
|
|
109
|
+
* object (not the wrapper), or null if missing/corrupt/version-mismatched.
|
|
110
|
+
*/
|
|
111
|
+
export function readSavedFingerprint(projectRoot) {
|
|
112
|
+
const path = resolve(projectRoot, FINGERPRINT_FILE_REL);
|
|
113
|
+
if (!existsSync(path)) return null;
|
|
114
|
+
try {
|
|
115
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
116
|
+
if (!data || data.version !== FINGERPRINT_VERSION) return null;
|
|
117
|
+
return data.fingerprint || null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Persist the fingerprint after a successful run. Wrapper carries a version
|
|
125
|
+
* and timestamp so a future schema change can invalidate gracefully.
|
|
126
|
+
*
|
|
127
|
+
* Returns true on success; false on write failure (caller should not fail
|
|
128
|
+
* the run on this).
|
|
129
|
+
*/
|
|
130
|
+
export function saveFingerprint(projectRoot, fp) {
|
|
131
|
+
const path = resolve(projectRoot, FINGERPRINT_FILE_REL);
|
|
132
|
+
const payload = {
|
|
133
|
+
version: FINGERPRINT_VERSION,
|
|
134
|
+
savedAt: new Date().toISOString(),
|
|
135
|
+
fingerprint: fp,
|
|
136
|
+
};
|
|
137
|
+
try {
|
|
138
|
+
writeFileSync(path, JSON.stringify(payload, null, 2));
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* High-level gate decision. Returns one of:
|
|
147
|
+
* { skip: true, reason: 'unchanged' }
|
|
148
|
+
* { skip: false, reason: 'forced' | 'no-saved-fingerprint' | 'inputs-changed', current }
|
|
149
|
+
*
|
|
150
|
+
* `current` is included on the run path so the caller can save it after a
|
|
151
|
+
* successful run without recomputing.
|
|
152
|
+
*/
|
|
153
|
+
export function decideIndexGate(projectRoot, env = process.env) {
|
|
154
|
+
if (env[FORCE_ENV]) {
|
|
155
|
+
return { skip: false, reason: 'forced', current: computeFingerprint(projectRoot) };
|
|
156
|
+
}
|
|
157
|
+
const current = computeFingerprint(projectRoot);
|
|
158
|
+
const saved = readSavedFingerprint(projectRoot);
|
|
159
|
+
if (!saved) return { skip: false, reason: 'no-saved-fingerprint', current };
|
|
160
|
+
if (fingerprintsEqual(current, saved)) return { skip: true, reason: 'unchanged' };
|
|
161
|
+
return { skip: false, reason: 'inputs-changed', current };
|
|
162
|
+
}
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.7",
|
|
4
4
|
"description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
|
|
5
5
|
"main": "dist/src/cli/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -40,14 +40,15 @@
|
|
|
40
40
|
"README.md",
|
|
41
41
|
"LICENSE",
|
|
42
42
|
"scripts/prune-native-binaries.mjs",
|
|
43
|
-
"scripts/post-install-notice.mjs"
|
|
43
|
+
"scripts/post-install-notice.mjs",
|
|
44
|
+
"scripts/post-install-bootstrap.mjs"
|
|
44
45
|
],
|
|
45
46
|
"scripts": {
|
|
46
47
|
"dev": "tsx watch src/cli/index.ts",
|
|
47
48
|
"prebuild": "node scripts/sync-version.mjs && node scripts/clean-dist.mjs",
|
|
48
49
|
"build": "tsc",
|
|
49
50
|
"prepublishOnly": "npm run build",
|
|
50
|
-
"postinstall": "node scripts/prune-native-binaries.mjs && node scripts/post-install-notice.mjs",
|
|
51
|
+
"postinstall": "node scripts/prune-native-binaries.mjs && node scripts/post-install-notice.mjs && node scripts/post-install-bootstrap.mjs",
|
|
51
52
|
"test": "node scripts/test-runner.mjs",
|
|
52
53
|
"test:ui": "vitest --ui",
|
|
53
54
|
"test:smoke": "node harness/consumer-smoke/run.mjs",
|
|
@@ -80,7 +81,7 @@
|
|
|
80
81
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
81
82
|
"@typescript-eslint/parser": "^7.18.0",
|
|
82
83
|
"eslint": "^8.0.0",
|
|
83
|
-
"moflo": "^4.9.
|
|
84
|
+
"moflo": "^4.9.6",
|
|
84
85
|
"tsx": "^4.21.0",
|
|
85
86
|
"typescript": "^5.9.3",
|
|
86
87
|
"vitest": "^4.0.0"
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Postinstall self-update bootstrap (#857).
|
|
4
|
+
*
|
|
5
|
+
* Problem this solves:
|
|
6
|
+
* The launcher in <consumer>/.claude/scripts/session-start-launcher.mjs
|
|
7
|
+
* is responsible for copying itself + helpers from node_modules/moflo/
|
|
8
|
+
* on every upgrade. Pre-#854 launchers wrap each copyFileSync in a bare
|
|
9
|
+
* `catch { /* non-fatal *\/ }` and can't reliably replace themselves on
|
|
10
|
+
* Windows under file-lock contention (EBUSY/EPERM/EACCES from concurrent
|
|
11
|
+
* helper invocation, AV real-time scan, npm verification handles).
|
|
12
|
+
*
|
|
13
|
+
* The fix for that lives in the new launcher (#854/#855), but the old
|
|
14
|
+
* launcher has to work to deploy the new one. It doesn't, and consumers
|
|
15
|
+
* stay stuck across 8+ version bumps until manually unstuck.
|
|
16
|
+
*
|
|
17
|
+
* Fix:
|
|
18
|
+
* This script runs at npm postinstall — driven by npm, not by the broken
|
|
19
|
+
* launcher — and copies bin/ scripts + helpers DIRECTLY into the
|
|
20
|
+
* consumer's .claude/scripts/ and .claude/helpers/. After the bootstrap
|
|
21
|
+
* runs, the next session-start launches the NEW launcher, which then
|
|
22
|
+
* handles the rest of the upgrade work (guidance sync, manifest, version
|
|
23
|
+
* stamp, daemon recycle, HNSW rebuild).
|
|
24
|
+
*
|
|
25
|
+
* The bootstrap only has to do enough to break the deadlock.
|
|
26
|
+
*
|
|
27
|
+
* The lists below MUST stay aligned with bin/session-start-launcher.mjs
|
|
28
|
+
* section 3 (the launcher's own sync). A unit test asserts list parity
|
|
29
|
+
* (mcp-tools-drift-guard pattern). See SCRIPT_FILES / BIN_HELPER_FILES /
|
|
30
|
+
* SOURCE_HELPER_FILES exports.
|
|
31
|
+
*
|
|
32
|
+
* Failure posture:
|
|
33
|
+
* - Surface per-file failures on stderr with `flo doctor --fix` advice
|
|
34
|
+
* - Skip silently if <consumer>/.claude doesn't exist (consumer hasn't
|
|
35
|
+
* run `flo init` yet — bootstrap is a no-op for first-time installs)
|
|
36
|
+
* - Never exit non-zero (postinstall failures block npm install)
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
copyFileSync,
|
|
41
|
+
existsSync,
|
|
42
|
+
mkdirSync,
|
|
43
|
+
readdirSync,
|
|
44
|
+
statSync,
|
|
45
|
+
} from 'node:fs';
|
|
46
|
+
import { dirname, join, resolve } from 'node:path';
|
|
47
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
48
|
+
|
|
49
|
+
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
50
|
+
const MOFLO_ROOT = resolve(dirname(SCRIPT_PATH), '..');
|
|
51
|
+
|
|
52
|
+
// ── Sync lists — keep in lockstep with bin/session-start-launcher.mjs §3 ─────
|
|
53
|
+
//
|
|
54
|
+
// Drift guard: tests/unit/post-install-bootstrap-drift.test.ts asserts these
|
|
55
|
+
// arrays match the launcher's section-3 sync lists by parsing both files.
|
|
56
|
+
|
|
57
|
+
export const SCRIPT_FILES = [
|
|
58
|
+
'hooks.mjs',
|
|
59
|
+
'session-start-launcher.mjs',
|
|
60
|
+
'index-guidance.mjs',
|
|
61
|
+
'build-embeddings.mjs',
|
|
62
|
+
'generate-code-map.mjs',
|
|
63
|
+
'semantic-search.mjs',
|
|
64
|
+
'index-tests.mjs',
|
|
65
|
+
'index-patterns.mjs',
|
|
66
|
+
'index-all.mjs',
|
|
67
|
+
'setup-project.mjs',
|
|
68
|
+
'run-migrations.mjs',
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
export const BIN_HELPER_FILES = [
|
|
72
|
+
'gate.cjs',
|
|
73
|
+
'gate-hook.mjs',
|
|
74
|
+
'prompt-hook.mjs',
|
|
75
|
+
'hook-handler.cjs',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
export const SOURCE_HELPER_FILES = [
|
|
79
|
+
'auto-memory-hook.mjs',
|
|
80
|
+
'statusline.cjs',
|
|
81
|
+
'intelligence.cjs',
|
|
82
|
+
'subagent-start.cjs',
|
|
83
|
+
'subagent-bootstrap.json',
|
|
84
|
+
'pre-commit',
|
|
85
|
+
'post-commit',
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// ── Retry + circuit breaker (#854 contract) ──────────────────────────────────
|
|
89
|
+
//
|
|
90
|
+
// Mirrors the launcher's syncWithRetry. Backoff [50,200,800]ms covers Windows
|
|
91
|
+
// EBUSY windows from concurrent helper invocation + AV real-time scan. The
|
|
92
|
+
// breaker opens after 5 distinct files exhaust retries so a sick host
|
|
93
|
+
// (AV mid-scan over node_modules) doesn't compound wall-clock cost.
|
|
94
|
+
|
|
95
|
+
const TRANSIENT_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
|
|
96
|
+
const RETRY_BACKOFF_MS = [50, 200, 800];
|
|
97
|
+
const CIRCUIT_BREAK_THRESHOLD = 5;
|
|
98
|
+
|
|
99
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
100
|
+
|
|
101
|
+
function makeSyncer() {
|
|
102
|
+
let circuitOpen = false;
|
|
103
|
+
const failures = [];
|
|
104
|
+
|
|
105
|
+
async function syncWithRetry(operation) {
|
|
106
|
+
const maxAttempts = circuitOpen ? 1 : RETRY_BACKOFF_MS.length + 1;
|
|
107
|
+
let lastErr = null;
|
|
108
|
+
let lastCode = null;
|
|
109
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
110
|
+
if (attempt > 0) await sleep(RETRY_BACKOFF_MS[attempt - 1]);
|
|
111
|
+
try {
|
|
112
|
+
operation();
|
|
113
|
+
return { ok: true };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
lastErr = err;
|
|
116
|
+
lastCode = err && err.code ? err.code : null;
|
|
117
|
+
if (!TRANSIENT_CODES.has(lastCode)) break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!circuitOpen && failures.length + 1 >= CIRCUIT_BREAK_THRESHOLD) {
|
|
121
|
+
circuitOpen = true;
|
|
122
|
+
}
|
|
123
|
+
return { ok: false, err: lastErr, code: lastCode };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function syncFile(src, dest, manifestKey) {
|
|
127
|
+
if (!existsSync(src)) return { skipped: true };
|
|
128
|
+
try {
|
|
129
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
failures.push({ key: manifestKey, message: errMessage(err) });
|
|
132
|
+
return { ok: false };
|
|
133
|
+
}
|
|
134
|
+
const result = await syncWithRetry(() => copyFileSync(src, dest));
|
|
135
|
+
if (result.ok) return { ok: true };
|
|
136
|
+
const tail = TRANSIENT_CODES.has(result.code)
|
|
137
|
+
? ` (retried ${RETRY_BACKOFF_MS.length}× after ${result.code}${circuitOpen ? '; circuit open' : ''})`
|
|
138
|
+
: '';
|
|
139
|
+
failures.push({ key: manifestKey, message: `${errMessage(result.err)}${tail}` });
|
|
140
|
+
return { ok: false };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { syncFile, failures };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function errMessage(err) {
|
|
147
|
+
if (!err) return 'unknown error';
|
|
148
|
+
return err.code ? `${err.code} ${err.message || ''}`.trim() : (err.message || String(err));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Project root discovery ──────────────────────────────────────────────────
|
|
152
|
+
//
|
|
153
|
+
// npm sets INIT_CWD to the directory where the user originally ran
|
|
154
|
+
// `npm install`. That's the consumer's project root regardless of which
|
|
155
|
+
// package's postinstall is running. Falls back to cwd for direct execution.
|
|
156
|
+
|
|
157
|
+
function consumerProjectRoot() {
|
|
158
|
+
return process.env.INIT_CWD || process.cwd();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Main bootstrap ──────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export async function runBootstrap({
|
|
164
|
+
projectRoot = consumerProjectRoot(),
|
|
165
|
+
mofloRoot = MOFLO_ROOT,
|
|
166
|
+
log = (msg) => process.stderr.write(`${msg}\n`),
|
|
167
|
+
} = {}) {
|
|
168
|
+
const claudeDir = resolve(projectRoot, '.claude');
|
|
169
|
+
if (!existsSync(claudeDir)) {
|
|
170
|
+
return { ran: false, reason: 'no-claude-dir' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// moflo's own dogfood install: don't bootstrap into the source repo.
|
|
174
|
+
// The source repo's .claude/ IS the truth source — we'd be copying
|
|
175
|
+
// the very files we just built ON TOP of themselves, which on Windows
|
|
176
|
+
// hits the same file-lock issues we're trying to avoid.
|
|
177
|
+
if (resolve(projectRoot) === resolve(mofloRoot)) {
|
|
178
|
+
return { ran: false, reason: 'moflo-self-install' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const binDir = resolve(mofloRoot, 'bin');
|
|
182
|
+
if (!existsSync(binDir)) {
|
|
183
|
+
return { ran: false, reason: 'no-bin-dir' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const { syncFile, failures } = makeSyncer();
|
|
187
|
+
let synced = 0;
|
|
188
|
+
|
|
189
|
+
// 1. Top-level scripts → .claude/scripts/
|
|
190
|
+
const scriptsDir = resolve(claudeDir, 'scripts');
|
|
191
|
+
if (!existsSync(scriptsDir)) mkdirSync(scriptsDir, { recursive: true });
|
|
192
|
+
for (const file of SCRIPT_FILES) {
|
|
193
|
+
const result = await syncFile(
|
|
194
|
+
resolve(binDir, file),
|
|
195
|
+
resolve(scriptsDir, file),
|
|
196
|
+
`.claude/scripts/${file}`,
|
|
197
|
+
);
|
|
198
|
+
if (result.ok) synced++;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 2. bin/lib/ → .claude/scripts/lib/ (read entire dir)
|
|
202
|
+
const libSrcDir = resolve(binDir, 'lib');
|
|
203
|
+
const libDestDir = resolve(scriptsDir, 'lib');
|
|
204
|
+
if (existsSync(libSrcDir)) {
|
|
205
|
+
if (!existsSync(libDestDir)) mkdirSync(libDestDir, { recursive: true });
|
|
206
|
+
let libEntries;
|
|
207
|
+
try {
|
|
208
|
+
libEntries = readdirSync(libSrcDir);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
log(`bootstrap: lib readdir failed (${errMessage(err)})`);
|
|
211
|
+
libEntries = [];
|
|
212
|
+
}
|
|
213
|
+
for (const file of libEntries) {
|
|
214
|
+
const src = resolve(libSrcDir, file);
|
|
215
|
+
try {
|
|
216
|
+
if (!statSync(src).isFile()) continue;
|
|
217
|
+
} catch { continue; }
|
|
218
|
+
const result = await syncFile(src, resolve(libDestDir, file), `.claude/scripts/lib/${file}`);
|
|
219
|
+
if (result.ok) synced++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 3. bin/migrations/ → .claude/scripts/migrations/ (recursive)
|
|
224
|
+
const migrationsSrcDir = resolve(binDir, 'migrations');
|
|
225
|
+
const migrationsDestDir = resolve(scriptsDir, 'migrations');
|
|
226
|
+
if (existsSync(migrationsSrcDir)) {
|
|
227
|
+
if (!existsSync(migrationsDestDir)) mkdirSync(migrationsDestDir, { recursive: true });
|
|
228
|
+
let migEntries;
|
|
229
|
+
try {
|
|
230
|
+
migEntries = readdirSync(migrationsSrcDir, { recursive: true, withFileTypes: true });
|
|
231
|
+
} catch (err) {
|
|
232
|
+
log(`bootstrap: migrations readdir failed (${errMessage(err)})`);
|
|
233
|
+
migEntries = [];
|
|
234
|
+
}
|
|
235
|
+
for (const entry of migEntries) {
|
|
236
|
+
if (!entry.isFile()) continue;
|
|
237
|
+
const parent = entry.parentPath || entry.path || migrationsSrcDir;
|
|
238
|
+
const absSrc = resolve(parent, entry.name);
|
|
239
|
+
const rel = absSrc.slice(migrationsSrcDir.length + 1).split(/[\\/]/).join('/');
|
|
240
|
+
const result = await syncFile(absSrc, resolve(migrationsDestDir, rel), `.claude/scripts/migrations/${rel}`);
|
|
241
|
+
if (result.ok) synced++;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 4. bin/ helpers → .claude/helpers/
|
|
246
|
+
const helpersDir = resolve(claudeDir, 'helpers');
|
|
247
|
+
if (!existsSync(helpersDir)) mkdirSync(helpersDir, { recursive: true });
|
|
248
|
+
for (const file of BIN_HELPER_FILES) {
|
|
249
|
+
const result = await syncFile(
|
|
250
|
+
resolve(binDir, file),
|
|
251
|
+
resolve(helpersDir, file),
|
|
252
|
+
`.claude/helpers/${file}`,
|
|
253
|
+
);
|
|
254
|
+
if (result.ok) synced++;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 5. moflo's own .claude/helpers/ → consumer .claude/helpers/
|
|
258
|
+
// (these never lived in bin/ — they're shipped via .claude/helpers/** in files[])
|
|
259
|
+
const sourceHelpersDir = resolve(mofloRoot, '.claude/helpers');
|
|
260
|
+
if (existsSync(sourceHelpersDir)) {
|
|
261
|
+
for (const file of SOURCE_HELPER_FILES) {
|
|
262
|
+
const src = resolve(sourceHelpersDir, file);
|
|
263
|
+
if (!existsSync(src)) continue;
|
|
264
|
+
const result = await syncFile(src, resolve(helpersDir, file), `.claude/helpers/${file}`);
|
|
265
|
+
if (result.ok) synced++;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Surface failures so npm log + Claude relay catches them, with the same
|
|
270
|
+
// healer advice the launcher uses.
|
|
271
|
+
if (failures.length > 0) {
|
|
272
|
+
const sample = failures.slice(0, 5).map((f) => ` - ${f.key}: ${f.message}`).join('\n');
|
|
273
|
+
const more = failures.length > 5 ? `\n …and ${failures.length - 5} more` : '';
|
|
274
|
+
log(
|
|
275
|
+
`moflo: postinstall bootstrap left ${failures.length} file(s) unsynced — run 'flo doctor --fix' to repair:\n${sample}${more}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { ran: true, synced, failed: failures.length, failures };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Entry point ─────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
285
|
+
runBootstrap()
|
|
286
|
+
.catch((err) => {
|
|
287
|
+
// Never block install. Log and exit 0.
|
|
288
|
+
process.stderr.write(`moflo: bootstrap failed (${errMessage(err)})\n`);
|
|
289
|
+
})
|
|
290
|
+
.finally(() => process.exit(0));
|
|
291
|
+
}
|