moflo 4.12.12 → 4.13.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.
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Worktree provisioning (#1481).
3
+ *
4
+ * `/flo -wt` and `flo worktree add` create a git worktree; on its own that is a
5
+ * valid checkout and an unrunnable workspace — no `node_modules`, none of the
6
+ * gitignored `.env` files, and no notion that a second worktree's dev servers
7
+ * will collide with the first on fixed ports. This service closes that gap,
8
+ * driven by the optional `worktree:` block in `moflo.yaml`.
9
+ *
10
+ * Every platform-sensitive decision lives here rather than in the command, so
11
+ * the Windows-vs-POSIX branches are reachable from a unit test (Rule #1):
12
+ * - paths built with `path.*`, never separator concatenation
13
+ * - directory links are junctions on Windows (no admin/developer mode needed,
14
+ * and the target must be absolute), plain symlinks on POSIX
15
+ * - copies via `fs.cpSync`; no `cp`/`ln -s`/`mkdir -p`/`find` shell-outs
16
+ * - `setup` runs through a shell on both platforms (it is a user-authored
17
+ * command string, not an argv array) — see `runSetup`
18
+ * - containment checks realpath BOTH sides before comparing (#1145: macOS
19
+ * `/var/folders` vs `/private/var/folders` otherwise false-positives)
20
+ */
21
+ import { spawnSync } from 'node:child_process';
22
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, symlinkSync, } from 'node:fs';
23
+ import path from 'node:path';
24
+ import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
25
+ import { globToRegExp } from '../guidance/retriever.js';
26
+ /** Relative path of the per-worktree state file, from the worktree root. */
27
+ export const WORKTREE_STATE_FILE = path.join('.moflo', 'worktree.json');
28
+ /**
29
+ * The same path in git's spelling. `git status --porcelain` reports forward
30
+ * slashes on every platform, so a caller comparing against porcelain output
31
+ * needs this form rather than the host-separator one above.
32
+ */
33
+ export const WORKTREE_STATE_FILE_POSIX = '.moflo/worktree.json';
34
+ /**
35
+ * Turn a branch name into a single flat directory name. Both separators are
36
+ * replaced: a branch is always `/`-delimited, but a caller may hand us a
37
+ * Windows-style string, and `\` is invalid in an NTFS directory name anyway.
38
+ */
39
+ export function slugifyBranch(branch) {
40
+ return branch.replace(/[\\/]/g, '-');
41
+ }
42
+ /**
43
+ * Resolve where a worktree for `branch` belongs.
44
+ *
45
+ * Default: `<repo-parent>/<repo-basename>-worktrees/<slugged-branch>` — a
46
+ * sibling of the checkout, so it is never inside the repo (and so never picked
47
+ * up by the repo's own tooling). `configuredDir` overrides the parent directory
48
+ * and is resolved relative to `repoRoot` when relative.
49
+ */
50
+ export function computeWorktreePath(repoRoot, branch, configuredDir) {
51
+ const parent = configuredDir
52
+ ? path.resolve(repoRoot, configuredDir)
53
+ : path.join(path.dirname(repoRoot), `${path.basename(repoRoot)}-worktrees`);
54
+ return path.join(parent, slugifyBranch(branch));
55
+ }
56
+ /**
57
+ * Smallest non-negative integer not already taken. Reusing a freed slot (rather
58
+ * than incrementing a counter) keeps the index small and stable, which matters
59
+ * because consumers offset fixed ports from it — an unbounded counter would
60
+ * eventually push a derived port out of range.
61
+ */
62
+ export function allocateIndex(existing) {
63
+ const taken = new Set(existing.filter(n => Number.isInteger(n) && n >= 0));
64
+ let candidate = 0;
65
+ while (taken.has(candidate))
66
+ candidate++;
67
+ return candidate;
68
+ }
69
+ /**
70
+ * Resolve a path as far as it exists. `realpathSync` throws on a missing path,
71
+ * but a containment check must still work for a destination that has not been
72
+ * created yet — so walk up to the nearest existing ancestor, resolve that, and
73
+ * re-append the remainder.
74
+ */
75
+ function realpathBestEffort(target) {
76
+ let current = path.resolve(target);
77
+ const trailing = [];
78
+ // Bounded by the path depth: each iteration removes one segment, and
79
+ // `path.dirname` is a fixed point at the filesystem root.
80
+ for (;;) {
81
+ if (existsSync(current))
82
+ return path.join(realpathSync(current), ...trailing.reverse());
83
+ const parent = path.dirname(current);
84
+ if (parent === current)
85
+ return path.resolve(target);
86
+ trailing.push(path.basename(current));
87
+ current = parent;
88
+ }
89
+ }
90
+ /**
91
+ * A canonical key for path identity: realpath'd, and case-folded on the two
92
+ * platforms whose filesystems are case-insensitive by default.
93
+ *
94
+ * Exported so a caller scanning a list resolves its needle ONCE and compares
95
+ * keys, rather than realpathing both sides on every iteration. Case-folding is
96
+ * what makes plain string equality safe here — `C:\Repo` and `C:\repo` are the
97
+ * same directory on Windows, and `/Users/x/Repo` and `/Users/x/repo` are the
98
+ * same directory on stock APFS.
99
+ */
100
+ export function resolveForCompare(target) {
101
+ const resolved = realpathBestEffort(target);
102
+ return process.platform === 'win32' || process.platform === 'darwin'
103
+ ? resolved.toLowerCase()
104
+ : resolved;
105
+ }
106
+ /**
107
+ * Is `candidate` inside `root`? Both sides go through
108
+ * {@link resolveForCompare} first — the #1145 shape, where an unresolved
109
+ * `/var/folders/...` compared against a resolved `/private/var/folders/...` on
110
+ * macOS made two identical paths look different.
111
+ */
112
+ export function isInside(root, candidate) {
113
+ const resolvedRoot = resolveForCompare(root);
114
+ const resolvedCandidate = resolveForCompare(candidate);
115
+ if (resolvedCandidate === resolvedRoot)
116
+ return true;
117
+ const rel = path.relative(resolvedRoot, resolvedCandidate);
118
+ // No `rel.length > 0` guard: `path.relative` returns '' for two spellings of
119
+ // the SAME directory that the equality check above missed, and treating that
120
+ // as "not inside" would break every identity test built on this.
121
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
122
+ }
123
+ /**
124
+ * Expand one `copy:` entry against the primary checkout.
125
+ *
126
+ * Glob support is deliberately narrow — a single `*` in the final segment, so
127
+ * `.env.*` works — because the alternative is either a shell-out (`find` does
128
+ * not exist on Windows) or a full tree walk on a pattern the user thought was
129
+ * cheap. A pattern needing more than this returns nothing rather than silently
130
+ * matching a subset; the caller reports it as skipped.
131
+ */
132
+ function expandCopyEntry(primaryRoot, entry) {
133
+ const normalized = entry.split(/[\\/]/).filter(Boolean);
134
+ if (normalized.length === 0)
135
+ return [];
136
+ const last = normalized[normalized.length - 1];
137
+ if (!last.includes('*')) {
138
+ const full = path.join(primaryRoot, ...normalized);
139
+ return existsSync(full) ? [full] : [];
140
+ }
141
+ const dir = path.join(primaryRoot, ...normalized.slice(0, -1));
142
+ if (!existsSync(dir))
143
+ return [];
144
+ // Reuse the guidance retriever's translator rather than hand-rolling one: it
145
+ // is anchored, escapes every metacharacter, and was already hardened for the
146
+ // `docs/*.md` matching `docsXmd` bug. Its `*` becomes `[^/]*`, which is exact
147
+ // here because these patterns match bare readdir NAMES, never a path.
148
+ const pattern = globToRegExp(last);
149
+ return readdirSync(dir)
150
+ .filter(name => pattern.test(name))
151
+ .sort()
152
+ .map(name => path.join(dir, name));
153
+ }
154
+ /**
155
+ * Does a config entry escape `root` before any symlink resolution?
156
+ *
157
+ * Deliberately LEXICAL, unlike {@link isInside}. "Did the user write a path that
158
+ * climbs out of the tree" is a property of the string they wrote, and resolving
159
+ * it first gets the answer wrong in the normal case: on a re-add, the worktree's
160
+ * `node_modules` is already a symlink into the primary checkout, so realpathing
161
+ * the destination reports it as outside the worktree and rejects a link that is
162
+ * exactly the one provisioning just made. A primary checkout whose own
163
+ * `node_modules` is a symlink (pnpm, a shared store) fails the same way.
164
+ */
165
+ function escapesRoot(root, entry) {
166
+ const rel = path.relative(root, path.resolve(root, entry));
167
+ return rel.startsWith('..') || path.isAbsolute(rel);
168
+ }
169
+ /**
170
+ * Copy the configured gitignored material into the new worktree.
171
+ *
172
+ * Sources are guarded on both ends: each must resolve inside the primary
173
+ * checkout (so `../secrets` is rejected), and the destination is always the
174
+ * worktree we just created. A missing source is skipped rather than fatal —
175
+ * `.env.local` legitimately does not exist on every machine.
176
+ */
177
+ function runCopy(primaryRoot, worktreePath, entries) {
178
+ const steps = [];
179
+ for (const entry of entries) {
180
+ if (!isInside(primaryRoot, path.resolve(primaryRoot, entry))) {
181
+ steps.push({
182
+ kind: 'copy',
183
+ target: entry,
184
+ status: 'failed',
185
+ detail: 'resolves outside the primary checkout',
186
+ });
187
+ continue;
188
+ }
189
+ const matches = expandCopyEntry(primaryRoot, entry);
190
+ if (matches.length === 0) {
191
+ steps.push({ kind: 'copy', target: entry, status: 'skipped', detail: 'no match' });
192
+ continue;
193
+ }
194
+ for (const source of matches) {
195
+ const dest = path.join(worktreePath, path.relative(primaryRoot, source));
196
+ try {
197
+ mkdirSync(path.dirname(dest), { recursive: true });
198
+ cpSync(source, dest, { recursive: true });
199
+ steps.push({ kind: 'copy', target: path.relative(primaryRoot, source), status: 'done' });
200
+ }
201
+ catch (error) {
202
+ steps.push({
203
+ kind: 'copy',
204
+ target: path.relative(primaryRoot, source),
205
+ status: 'failed',
206
+ detail: error instanceof Error ? error.message : String(error),
207
+ });
208
+ }
209
+ }
210
+ }
211
+ return steps;
212
+ }
213
+ /**
214
+ * The `fs.symlinkSync` type argument for a directory link on this platform.
215
+ *
216
+ * Windows gets a junction: unlike a `'dir'` symlink it needs no admin rights or
217
+ * developer mode. Exported and pure so the Windows branch is assertable from a
218
+ * unit test on any host — the alternative, spying on an ESM `fs` export, is not
219
+ * possible, and gating the assertion on a Windows runner would leave the branch
220
+ * unverified on the two CI legs that run most often.
221
+ */
222
+ export function linkTypeForPlatform(platform) {
223
+ return platform === 'win32' ? 'junction' : undefined;
224
+ }
225
+ /**
226
+ * Link the configured paths from the primary checkout into the new worktree.
227
+ * The target is resolved to an ABSOLUTE path before the call on every platform:
228
+ * a relative target silently produces a broken junction on Windows.
229
+ */
230
+ function runLink(primaryRoot, worktreePath, entries) {
231
+ const steps = [];
232
+ const linkType = linkTypeForPlatform(process.platform);
233
+ for (const entry of entries) {
234
+ const source = path.resolve(primaryRoot, entry);
235
+ const dest = path.resolve(worktreePath, entry);
236
+ // Guard BOTH ends against an escaping entry: `link: ["../x"]` would
237
+ // otherwise source from outside the checkout and write the link outside the
238
+ // worktree. Lexical on purpose — see escapesRoot().
239
+ if (escapesRoot(primaryRoot, entry) || escapesRoot(worktreePath, entry)) {
240
+ steps.push({
241
+ kind: 'link',
242
+ target: entry,
243
+ status: 'failed',
244
+ detail: 'resolves outside the primary checkout or the worktree',
245
+ });
246
+ continue;
247
+ }
248
+ if (!existsSync(source)) {
249
+ steps.push({ kind: 'link', target: entry, status: 'skipped', detail: 'no such path' });
250
+ continue;
251
+ }
252
+ // lstat, not existsSync: a broken symlink left by an earlier run still
253
+ // occupies the name, and clobbering it is not ours to decide.
254
+ let occupied = false;
255
+ try {
256
+ lstatSync(dest);
257
+ occupied = true;
258
+ }
259
+ catch {
260
+ occupied = false;
261
+ }
262
+ if (occupied) {
263
+ steps.push({ kind: 'link', target: entry, status: 'skipped', detail: 'already exists' });
264
+ continue;
265
+ }
266
+ try {
267
+ mkdirSync(path.dirname(dest), { recursive: true });
268
+ symlinkSync(source, dest, linkType);
269
+ steps.push({ kind: 'link', target: entry, status: 'done' });
270
+ }
271
+ catch (error) {
272
+ steps.push({
273
+ kind: 'link',
274
+ target: entry,
275
+ status: 'failed',
276
+ detail: error instanceof Error ? error.message : String(error),
277
+ });
278
+ }
279
+ }
280
+ return steps;
281
+ }
282
+ /**
283
+ * Run the configured `setup` command inside the new worktree.
284
+ *
285
+ * `shell: true` on BOTH platforms, unlike the daemon-spawning code this repo
286
+ * models elsewhere. That rule ("shell on Windows, detached on POSIX") is about
287
+ * spawning a known binary with an argv array; `setup` is a user-authored shell
288
+ * command *string* from `moflo.yaml` — `npm ci && npm run build` is a legitimate
289
+ * value, and it needs `cmd.exe` or `/bin/sh` to mean anything. Running it
290
+ * without a shell would exec a file literally named `npm ci && npm run build`.
291
+ * On Windows a shell is required regardless, since `npm` there is `npm.cmd`.
292
+ * Trust boundary: the same as a `package.json` script — the project's own config.
293
+ *
294
+ * `jsonMode` sends the child's stdout to our stderr so a `--json` caller still
295
+ * gets parseable JSON on stdout.
296
+ */
297
+ function runSetup(worktreePath, command, index, jsonMode) {
298
+ const result = spawnSync(command, {
299
+ cwd: worktreePath,
300
+ shell: true,
301
+ stdio: jsonMode ? ['ignore', 2, 2] : 'inherit',
302
+ env: { ...process.env, MOFLO_WORKTREE_INDEX: String(index) },
303
+ });
304
+ if (result.error) {
305
+ return { kind: 'setup', target: command, status: 'failed', detail: result.error.message };
306
+ }
307
+ if (result.status !== 0) {
308
+ return {
309
+ kind: 'setup',
310
+ target: command,
311
+ status: 'failed',
312
+ detail: `exited with code ${result.status ?? 'null'}`,
313
+ };
314
+ }
315
+ return { kind: 'setup', target: command, status: 'done' };
316
+ }
317
+ /**
318
+ * Provision a freshly created worktree: copy, then link, then setup.
319
+ *
320
+ * Order matters — `setup` (typically `npm ci`) may depend on the `.env` files
321
+ * `copy` brings in, and must not race the `link` that would otherwise supply
322
+ * `node_modules`. A failed step never unwinds the worktree: it is a valid
323
+ * checkout either way, and deleting a tree the user may have started working in
324
+ * is far worse than leaving it under-provisioned.
325
+ */
326
+ export function provisionWorktree(opts) {
327
+ const { primaryRoot, worktreePath, branch, index, config, jsonMode = false } = opts;
328
+ const steps = [];
329
+ if (config?.copy?.length)
330
+ steps.push(...runCopy(primaryRoot, worktreePath, config.copy));
331
+ if (config?.link?.length)
332
+ steps.push(...runLink(primaryRoot, worktreePath, config.link));
333
+ if (config?.setup)
334
+ steps.push(runSetup(worktreePath, config.setup, index, jsonMode));
335
+ const provisioned = steps.every(step => step.status !== 'failed');
336
+ writeWorktreeState(worktreePath, { branch, index, primaryRoot, provisioned });
337
+ return { provisioned, steps };
338
+ }
339
+ /**
340
+ * Did `copy:`/`link:` put this worktree-relative path there?
341
+ *
342
+ * Lives here, next to {@link provisionWorktree}, because answering it needs the
343
+ * same glob translation provisioning used: a raw config entry is not a filename.
344
+ * Comparing `.env.*` literally against the `.env.local` git reports never
345
+ * matches, which would blame provisioning's own files on the user.
346
+ *
347
+ * Every ancestor prefix is tested, so a directory entry (glob or literal) also
348
+ * claims the files inside it — `git status -uall` reports those individually.
349
+ *
350
+ * @param target a path in git's spelling (forward slashes), relative to the worktree
351
+ */
352
+ export function isProvisionedPath(target, config) {
353
+ const entries = [...(config?.copy ?? []), ...(config?.link ?? [])]
354
+ .map(entry => entry.split(/[\\/]/).filter(Boolean).join('/'))
355
+ .filter(Boolean);
356
+ if (entries.length === 0)
357
+ return false;
358
+ const segments = target.split('/').filter(Boolean);
359
+ for (const entry of entries) {
360
+ const matcher = entry.includes('*') ? globToRegExp(entry) : null;
361
+ for (let depth = 1; depth <= segments.length; depth++) {
362
+ const prefix = segments.slice(0, depth).join('/');
363
+ if (matcher ? matcher.test(prefix) : prefix === entry)
364
+ return true;
365
+ }
366
+ }
367
+ return false;
368
+ }
369
+ /** Write `.moflo/worktree.json` into a worktree. Atomic — the daemon may read it. */
370
+ export function writeWorktreeState(worktreePath, state) {
371
+ const statePath = path.join(worktreePath, WORKTREE_STATE_FILE);
372
+ mkdirSync(path.dirname(statePath), { recursive: true });
373
+ atomicWriteFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
374
+ }
375
+ /**
376
+ * Read a worktree's moflo state. `null` means moflo did not create this tree —
377
+ * which is exactly how `flo worktree list` reports an externally-created
378
+ * worktree as unprovisioned, so a malformed file is treated the same as a
379
+ * missing one rather than failing the listing.
380
+ */
381
+ export function readWorktreeState(worktreePath) {
382
+ const statePath = path.join(worktreePath, WORKTREE_STATE_FILE);
383
+ if (!existsSync(statePath))
384
+ return null;
385
+ try {
386
+ const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
387
+ if (typeof parsed.branch !== 'string' || !Number.isInteger(parsed.index))
388
+ return null;
389
+ return {
390
+ branch: parsed.branch,
391
+ index: parsed.index,
392
+ primaryRoot: typeof parsed.primaryRoot === 'string' ? parsed.primaryRoot : '',
393
+ provisioned: parsed.provisioned === true,
394
+ };
395
+ }
396
+ catch {
397
+ return null;
398
+ }
399
+ }
400
+ //# sourceMappingURL=worktree-provision.js.map
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.12.12';
5
+ export const VERSION = '4.13.1';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.12.12",
3
+ "version": "4.13.1",
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",
@@ -99,7 +99,7 @@
99
99
  "@typescript-eslint/parser": "^8.65.0",
100
100
  "eslint": "^10.8.0",
101
101
  "glob": "^11.1.0",
102
- "moflo": "^4.12.11",
102
+ "moflo": "^4.13.0",
103
103
  "tsx": "^4.21.0",
104
104
  "typescript": "^5.9.3",
105
105
  "vitest": "^4.0.0"