@gr8ful/spf 0.5.0 → 0.5.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/dist/cli/commands/watch.js +11 -0
- package/dist/core/paths.d.ts +3 -0
- package/dist/core/paths.js +48 -1
- package/dist/test/paths.test.d.ts +1 -0
- package/dist/test/paths.test.js +68 -0
- package/package.json +1 -1
|
@@ -260,6 +260,17 @@ export async function watchCommand(argv) {
|
|
|
260
260
|
*/
|
|
261
261
|
function linkDataDir(worktreePath) {
|
|
262
262
|
const target = path.join(worktreePath, ".spf", "data");
|
|
263
|
+
// Hard guard, independent of however `worktreePath` got resolved: a real
|
|
264
|
+
// self-referential .spf/data symlink was observed live once already
|
|
265
|
+
// (points at itself — every later `mkdirSync` through it throws ELOOP;
|
|
266
|
+
// see `paths.healBrokenDataDir` for the recovery side of this same bug).
|
|
267
|
+
// The exact trigger was never pinned down, but `worktreePath` resolving
|
|
268
|
+
// to the main repo's own `data_dir` can never be correct regardless of
|
|
269
|
+
// how it got there, so refuse outright rather than create it again.
|
|
270
|
+
if (path.resolve(target) === path.resolve(dataPaths.data_dir)) {
|
|
271
|
+
console.error(`watch: refusing to link ${target} to itself — worktree path resolved to the main repo's own data_dir`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
263
274
|
if (existsSync(target))
|
|
264
275
|
return;
|
|
265
276
|
mkdirSync(path.dirname(target), { recursive: true });
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -67,6 +67,9 @@ export interface DataPaths {
|
|
|
67
67
|
* alwaysWritable()` matches `cfg.defaults.data_dir` against repo-relative git
|
|
68
68
|
* output, so that field stays exactly as configured. This produces a
|
|
69
69
|
* parallel, absolute set of paths for filesystem use instead of mutating it.
|
|
70
|
+
*
|
|
71
|
+
* Not otherwise side-effect-free: see `healBrokenDataDir` above, which every
|
|
72
|
+
* caller needs run before its own first `mkdirSync` against `data_dir`.
|
|
70
73
|
*/
|
|
71
74
|
export declare function resolveDataPaths(anchor: RepoAnchor, rawDataDir: string, rawDbPath: string): DataPaths;
|
|
72
75
|
/**
|
package/dist/core/paths.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* levels below the package root, so the same relative path resolves either
|
|
17
17
|
* way), with `assets/` a direct child of it.
|
|
18
18
|
*/
|
|
19
|
-
import { existsSync, statSync } from "node:fs";
|
|
19
|
+
import { existsSync, lstatSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { findRepoRoot } from "./git_helper.js";
|
|
22
22
|
export const PACKAGE_ROOT = path.resolve(import.meta.dirname, "..", "..");
|
|
@@ -67,6 +67,49 @@ export function resolveConfigPaths(anchor, explicit) {
|
|
|
67
67
|
}
|
|
68
68
|
return { paths: [BUILTIN_CONFIG_PATH], source: "built-in-only" };
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Self-heals one specific, previously-observed failure mode: `data_dir`
|
|
72
|
+
* existing as a broken symlink — self-referential (points at itself) or
|
|
73
|
+
* dangling (points at something that no longer exists) — instead of a real
|
|
74
|
+
* directory. `spf watch`'s `linkDataDir` guards against ever CREATING one of
|
|
75
|
+
* these now (see its comment in `cli/commands/watch.ts`), but a prior,
|
|
76
|
+
* unguarded build already left one behind on at least one real checkout, and
|
|
77
|
+
* that state persists across every later `spf` invocation until something
|
|
78
|
+
* removes it: `mkdirSync(data_dir, {recursive: true})` — the very first
|
|
79
|
+
* filesystem touch nearly every command makes, whether via `ensureDir`'s
|
|
80
|
+
* session dirs or `acquireLock`'s lockfile dir — throws `ELOOP` before
|
|
81
|
+
* anything else runs, with no recovery path of its own.
|
|
82
|
+
*
|
|
83
|
+
* This has to run HERE, in the one function every one of those call sites
|
|
84
|
+
* resolves `data_dir` through, rather than as a guard inside each of them
|
|
85
|
+
* individually — a fix duplicated at every call site is a fix that's one new
|
|
86
|
+
* call site away from being missed again.
|
|
87
|
+
*
|
|
88
|
+
* `lstatSync` (never follows the link itself) distinguishes "nothing here
|
|
89
|
+
* yet" (the ordinary first-run case — silently returns, nothing to heal)
|
|
90
|
+
* from "a symlink sits here." Only for the symlink case does `realpathSync`
|
|
91
|
+
* (which fully resolves it) get a chance to throw — `ELOOP` for a circular
|
|
92
|
+
* link, `ENOENT` for a dangling target — and only then is anything removed.
|
|
93
|
+
* A real directory never reaches the `realpathSync` call at all.
|
|
94
|
+
*/
|
|
95
|
+
function healBrokenDataDir(data_dir) {
|
|
96
|
+
let entry;
|
|
97
|
+
try {
|
|
98
|
+
entry = lstatSync(data_dir);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return; // nothing at this path at all — ordinary first run
|
|
102
|
+
}
|
|
103
|
+
if (!entry.isSymbolicLink())
|
|
104
|
+
return; // a real directory (or file) — not this bug
|
|
105
|
+
try {
|
|
106
|
+
realpathSync(data_dir);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
rmSync(data_dir);
|
|
110
|
+
console.error(`spf: removed a broken data_dir symlink at ${data_dir} (self-referential or dangling) — a fresh directory will be created in its place`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
70
113
|
/**
|
|
71
114
|
* Anchor a config's own `data_dir`/`observability.db` (as loaded — relative
|
|
72
115
|
* or absolute, whichever the YAML says) to `repo_root`.
|
|
@@ -75,9 +118,13 @@ export function resolveConfigPaths(anchor, explicit) {
|
|
|
75
118
|
* alwaysWritable()` matches `cfg.defaults.data_dir` against repo-relative git
|
|
76
119
|
* output, so that field stays exactly as configured. This produces a
|
|
77
120
|
* parallel, absolute set of paths for filesystem use instead of mutating it.
|
|
121
|
+
*
|
|
122
|
+
* Not otherwise side-effect-free: see `healBrokenDataDir` above, which every
|
|
123
|
+
* caller needs run before its own first `mkdirSync` against `data_dir`.
|
|
78
124
|
*/
|
|
79
125
|
export function resolveDataPaths(anchor, rawDataDir, rawDbPath) {
|
|
80
126
|
const data_dir = path.resolve(anchor.repo_root, rawDataDir);
|
|
127
|
+
healBrokenDataDir(data_dir);
|
|
81
128
|
const data_dir_rel = path.relative(anchor.repo_root, data_dir).split(path.sep).join("/");
|
|
82
129
|
const db_path = path.resolve(anchor.repo_root, rawDbPath);
|
|
83
130
|
const sessions_dir = path.resolve(path.dirname(db_path), "sessions");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression guard for `resolveDataPaths`'s self-heal: a `data_dir` that
|
|
3
|
+
* exists as a broken symlink (self-referential or dangling) must be removed
|
|
4
|
+
* before anything tries to `mkdirSync` through it — see `paths.ts`'s
|
|
5
|
+
* `healBrokenDataDir` for why this lives here and not at each `mkdirSync`
|
|
6
|
+
* call site individually. This is a live bug that shipped once already: a
|
|
7
|
+
* self-referential `.spf/data -> .spf/data` symlink was observed on a real
|
|
8
|
+
* checkout, and every subsequent command failed with `ELOOP` at its very
|
|
9
|
+
* first filesystem touch.
|
|
10
|
+
*/
|
|
11
|
+
import { test, beforeEach, afterEach } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { resolveAnchor, resolveDataPaths } from "../core/paths.js";
|
|
17
|
+
let dir;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
dir = mkdtempSync(join(tmpdir(), "spf-paths-test-"));
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
test("resolveDataPaths: a self-referential data_dir symlink is removed, not left to ELOOP later", () => {
|
|
25
|
+
const dataDir = join(dir, ".spf", "data");
|
|
26
|
+
mkdirSync(join(dir, ".spf"), { recursive: true });
|
|
27
|
+
symlinkSync(dataDir, dataDir, "dir"); // exactly the observed bug: points at itself
|
|
28
|
+
const anchor = resolveAnchor(dir);
|
|
29
|
+
const paths = resolveDataPaths(anchor, ".spf/data", ".spf/data/spf.db");
|
|
30
|
+
assert.equal(paths.data_dir, dataDir);
|
|
31
|
+
assert.equal(existsSync(dataDir), false, "the broken symlink must be gone, not just tolerated");
|
|
32
|
+
// Confirms the actual failure mode is fixed: this would throw ELOOP before the fix.
|
|
33
|
+
mkdirSync(dataDir, { recursive: true });
|
|
34
|
+
assert.ok(existsSync(dataDir));
|
|
35
|
+
});
|
|
36
|
+
test("resolveDataPaths: a dangling data_dir symlink (target removed) is also cleaned up", () => {
|
|
37
|
+
const realTarget = join(dir, "somewhere-else");
|
|
38
|
+
const dataDir = join(dir, ".spf", "data");
|
|
39
|
+
mkdirSync(realTarget, { recursive: true });
|
|
40
|
+
mkdirSync(join(dir, ".spf"), { recursive: true });
|
|
41
|
+
symlinkSync(realTarget, dataDir, "dir");
|
|
42
|
+
rmSync(realTarget, { recursive: true, force: true }); // now dataDir points at nothing
|
|
43
|
+
const anchor = resolveAnchor(dir);
|
|
44
|
+
resolveDataPaths(anchor, ".spf/data", ".spf/data/spf.db");
|
|
45
|
+
assert.equal(existsSync(dataDir), false);
|
|
46
|
+
});
|
|
47
|
+
test("resolveDataPaths: an ordinary real directory is left completely alone", () => {
|
|
48
|
+
const dataDir = join(dir, ".spf", "data");
|
|
49
|
+
mkdirSync(dataDir, { recursive: true });
|
|
50
|
+
writeFileSync(join(dataDir, "spf.db"), "not actually sqlite, just a marker");
|
|
51
|
+
const anchor = resolveAnchor(dir);
|
|
52
|
+
resolveDataPaths(anchor, ".spf/data", ".spf/data/spf.db");
|
|
53
|
+
assert.ok(existsSync(join(dataDir, "spf.db")), "a real, already-populated data_dir must never be touched");
|
|
54
|
+
});
|
|
55
|
+
test("resolveDataPaths: data_dir not existing yet (the ordinary first-run case) is a silent no-op", () => {
|
|
56
|
+
const anchor = resolveAnchor(dir);
|
|
57
|
+
assert.doesNotThrow(() => resolveDataPaths(anchor, ".spf/data", ".spf/data/spf.db"));
|
|
58
|
+
assert.equal(existsSync(join(dir, ".spf", "data")), false, "resolveDataPaths itself never creates the directory — only heals a broken one");
|
|
59
|
+
});
|
|
60
|
+
test("resolveDataPaths: a real FILE at data_dir's path (not a symlink) is left alone — not this bug", () => {
|
|
61
|
+
mkdirSync(join(dir, ".spf"), { recursive: true });
|
|
62
|
+
const dataDir = join(dir, ".spf", "data");
|
|
63
|
+
writeFileSync(dataDir, "oops, a file where a directory should be");
|
|
64
|
+
const anchor = resolveAnchor(dir);
|
|
65
|
+
resolveDataPaths(anchor, ".spf/data", ".spf/data/spf.db");
|
|
66
|
+
assert.ok(existsSync(dataDir), "only a broken SYMLINK is this function's concern, not any other kind of misconfiguration");
|
|
67
|
+
assert.equal(lstatSync(dataDir).isSymbolicLink(), false);
|
|
68
|
+
});
|