@andares/pi-toolkits 0.8.0 → 0.9.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/README.md +66 -2
- package/README.zh-CN.md +65 -2
- package/package.json +7 -3
- package/src/features/ask/index.test.ts +118 -0
- package/src/features/ask/index.ts +31 -13
- package/src/features/autostart/autostart.test.ts +45 -2
- package/src/features/autostart/autostart.ts +4 -2
- package/src/features/autostart/index.ts +100 -48
- package/src/features/permission/constants.test.ts +60 -0
- package/src/features/permission/constants.ts +158 -0
- package/src/features/permission/index.test.ts +340 -0
- package/src/features/permission/index.ts +333 -0
- package/src/features/permission/modes.test.ts +66 -0
- package/src/features/permission/modes.ts +82 -0
- package/src/features/permission/panel.ts +118 -0
- package/src/features/permission/paths.test.ts +111 -0
- package/src/features/permission/paths.ts +126 -0
- package/src/features/permission/policy.test.ts +237 -0
- package/src/features/permission/policy.ts +338 -0
- package/src/features/permission/prompt.ts +146 -0
- package/src/features/permission/session-rules.ts +99 -0
- package/src/features/permission/units.test.ts +205 -0
- package/src/features/permission/units.ts +995 -0
- package/src/index.ts +15 -2
- package/src/lib/gates.test.ts +171 -0
- package/src/lib/gates.ts +153 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mode defaults, argument parsing, and formatting.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { DEFAULT_MODES, IN_VALUES, OUT_VALUES } from "./constants.js";
|
|
6
|
+
import { formatModes, parsePermissionArgs, shortMode } from "./modes.js";
|
|
7
|
+
|
|
8
|
+
describe("modes", () => {
|
|
9
|
+
it("ships the documented defaults", () => {
|
|
10
|
+
expect(DEFAULT_MODES).toEqual({
|
|
11
|
+
inRead: "allow",
|
|
12
|
+
inWrite: "allow",
|
|
13
|
+
outRead: "allow",
|
|
14
|
+
outWrite: "ask",
|
|
15
|
+
});
|
|
16
|
+
expect(IN_VALUES).toEqual(["allow", "ask"]);
|
|
17
|
+
expect(OUT_VALUES).toEqual(["allow", "ask", "deny"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("merges missing arguments from the defaults", () => {
|
|
21
|
+
expect(parsePermissionArgs("ask")).toEqual({
|
|
22
|
+
modes: { inRead: "ask", inWrite: "allow", outRead: "allow", outWrite: "ask" },
|
|
23
|
+
});
|
|
24
|
+
expect(parsePermissionArgs("ask ask")).toEqual({
|
|
25
|
+
modes: { inRead: "ask", inWrite: "ask", outRead: "allow", outWrite: "ask" },
|
|
26
|
+
});
|
|
27
|
+
expect(parsePermissionArgs("ask allow deny deny")).toEqual({
|
|
28
|
+
modes: { inRead: "ask", inWrite: "allow", outRead: "deny", outWrite: "deny" },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("uses the current defaults, not the previous mode, for omitted arguments", () => {
|
|
33
|
+
const parsed = parsePermissionArgs("allow");
|
|
34
|
+
expect("modes" in parsed ? parsed.modes : undefined).toEqual(DEFAULT_MODES);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects deny for in-project modes", () => {
|
|
38
|
+
expect(parsePermissionArgs("deny")).toEqual({
|
|
39
|
+
error: expect.stringContaining("in-read"),
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects unknown values and too many arguments", () => {
|
|
44
|
+
expect(parsePermissionArgs("maybe")).toEqual({
|
|
45
|
+
error: expect.stringContaining("in-read"),
|
|
46
|
+
});
|
|
47
|
+
expect(parsePermissionArgs("allow allow allow nope")).toEqual({
|
|
48
|
+
error: expect.stringContaining("out-write"),
|
|
49
|
+
});
|
|
50
|
+
expect(parsePermissionArgs("allow allow allow ask extra")).toEqual({
|
|
51
|
+
error: expect.stringContaining("at most 4"),
|
|
52
|
+
});
|
|
53
|
+
expect(parsePermissionArgs("")).toEqual({
|
|
54
|
+
error: expect.stringContaining("at least one"),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("formats the four modes and their short letters", () => {
|
|
59
|
+
expect(formatModes(DEFAULT_MODES)).toBe("in allow/allow, out allow/ask");
|
|
60
|
+
expect([shortMode("allow"), shortMode("ask"), shortMode("deny")]).toEqual([
|
|
61
|
+
"A",
|
|
62
|
+
"a",
|
|
63
|
+
"D",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permission feature — argument parsing and display formatting.
|
|
3
|
+
*
|
|
4
|
+
* `/ask-perm` with no argument toggles: on → off, off → panel. With arguments
|
|
5
|
+
* it configures and enables in one step (this is the autostart path, and it
|
|
6
|
+
* never opens a dialog):
|
|
7
|
+
*
|
|
8
|
+
* /ask-perm [in-read] [in-write] [out-read] [out-write]
|
|
9
|
+
*
|
|
10
|
+
* Missing arguments take their default value — not the current one — so
|
|
11
|
+
* `/ask-perm allow` always means "defaults, which also happens to be all
|
|
12
|
+
* allow on the inside".
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_MODES,
|
|
16
|
+
IN_VALUES,
|
|
17
|
+
OUT_VALUES,
|
|
18
|
+
type PermissionModes,
|
|
19
|
+
type TriMode,
|
|
20
|
+
} from "./constants.js";
|
|
21
|
+
|
|
22
|
+
export type ParsedArgs =
|
|
23
|
+
| { readonly modes: PermissionModes }
|
|
24
|
+
| { readonly error: string };
|
|
25
|
+
|
|
26
|
+
function isPair(value: string): boolean {
|
|
27
|
+
return (IN_VALUES as readonly string[]).includes(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isTri(value: string): boolean {
|
|
31
|
+
return (OUT_VALUES as readonly string[]).includes(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse the command arguments. An empty string yields an error — callers must
|
|
36
|
+
* check for "no arguments" before calling this.
|
|
37
|
+
*/
|
|
38
|
+
export function parsePermissionArgs(raw: string): ParsedArgs {
|
|
39
|
+
const words = raw.trim().split(/\s+/).filter(Boolean);
|
|
40
|
+
if (words.length === 0) return { error: "expected at least one mode" };
|
|
41
|
+
if (words.length > 4) {
|
|
42
|
+
return { error: "expected at most 4 modes (in-read in-write out-read out-write)" };
|
|
43
|
+
}
|
|
44
|
+
const [inRead, inWrite, outRead, outWrite] = words;
|
|
45
|
+
if (inRead !== undefined && !isPair(inRead)) {
|
|
46
|
+
return { error: `in-read must be one of ${IN_VALUES.join("|")} (got "${inRead}")` };
|
|
47
|
+
}
|
|
48
|
+
if (inWrite !== undefined && !isPair(inWrite)) {
|
|
49
|
+
return { error: `in-write must be one of ${IN_VALUES.join("|")} (got "${inWrite}")` };
|
|
50
|
+
}
|
|
51
|
+
if (outRead !== undefined && !isTri(outRead)) {
|
|
52
|
+
return { error: `out-read must be one of ${OUT_VALUES.join("|")} (got "${outRead}")` };
|
|
53
|
+
}
|
|
54
|
+
if (outWrite !== undefined && !isTri(outWrite)) {
|
|
55
|
+
return { error: `out-write must be one of ${OUT_VALUES.join("|")} (got "${outWrite}")` };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
modes: {
|
|
59
|
+
inRead: (inRead ?? DEFAULT_MODES.inRead) as PermissionModes["inRead"],
|
|
60
|
+
inWrite: (inWrite ?? DEFAULT_MODES.inWrite) as PermissionModes["inWrite"],
|
|
61
|
+
outRead: (outRead ?? DEFAULT_MODES.outRead) as TriMode,
|
|
62
|
+
outWrite: (outWrite ?? DEFAULT_MODES.outWrite) as TriMode,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `allow/allow · allow/ask`, the human-readable summary used in notices. */
|
|
68
|
+
export function formatModes(modes: PermissionModes): string {
|
|
69
|
+
return `in ${modes.inRead}/${modes.inWrite}, out ${modes.outRead}/${modes.outWrite}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Single-letter form for the footer status (`A` allow, `a` ask, `D` deny). */
|
|
73
|
+
export function shortMode(mode: TriMode | "allow"): string {
|
|
74
|
+
switch (mode) {
|
|
75
|
+
case "allow":
|
|
76
|
+
return "A";
|
|
77
|
+
case "ask":
|
|
78
|
+
return "a";
|
|
79
|
+
case "deny":
|
|
80
|
+
return "D";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permission feature — the mode panel.
|
|
3
|
+
*
|
|
4
|
+
* A `SettingsList` (the same component the built-in settings dialogs use)
|
|
5
|
+
* supplies rendering, ↑↓ navigation, space-cycle and Esc-cancel. A thin
|
|
6
|
+
* wrapper adds the one behaviour this dialog needs on top: **Enter anywhere
|
|
7
|
+
* applies the current values**, so typing `/ask-perm` then Enter enables the
|
|
8
|
+
* defaults without navigating.
|
|
9
|
+
*
|
|
10
|
+
* TUI only: `SettingsList` is a custom component, and rpc/print modes cannot
|
|
11
|
+
* host one. Callers fall back to the argument form there.
|
|
12
|
+
*/
|
|
13
|
+
import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import {
|
|
16
|
+
type Component,
|
|
17
|
+
matchesKey,
|
|
18
|
+
type SettingItem,
|
|
19
|
+
SettingsList,
|
|
20
|
+
} from "@earendil-works/pi-tui";
|
|
21
|
+
import {
|
|
22
|
+
IN_VALUES,
|
|
23
|
+
OUT_VALUES,
|
|
24
|
+
type PermissionModes,
|
|
25
|
+
} from "./constants.js";
|
|
26
|
+
|
|
27
|
+
/** Intercepts Enter globally; everything else belongs to the list. */
|
|
28
|
+
class ApplyOnEnter implements Component {
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly list: SettingsList,
|
|
31
|
+
private readonly snapshot: () => PermissionModes,
|
|
32
|
+
private readonly done: (value: PermissionModes | undefined) => void,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
handleInput(data: string): void {
|
|
36
|
+
if (matchesKey(data, "enter")) {
|
|
37
|
+
this.done(this.snapshot());
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.list.handleInput(data);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
render(width: number): string[] {
|
|
44
|
+
return this.list.render(width);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
invalidate(): void {
|
|
48
|
+
this.list.invalidate();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build the settings rows for one mode set. */
|
|
53
|
+
function buildItems(modes: PermissionModes): SettingItem[] {
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
id: "apply",
|
|
57
|
+
label: "Enable with these modes",
|
|
58
|
+
description: "Press Enter to enable permission checks",
|
|
59
|
+
currentValue: "enter",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "inRead",
|
|
63
|
+
label: "In-project read",
|
|
64
|
+
description: "Reads inside the working directory and added directories",
|
|
65
|
+
currentValue: modes.inRead,
|
|
66
|
+
values: [...IN_VALUES],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "inWrite",
|
|
70
|
+
label: "In-project write / exec",
|
|
71
|
+
description: "Writes and command execution inside the project",
|
|
72
|
+
currentValue: modes.inWrite,
|
|
73
|
+
values: [...IN_VALUES],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "outRead",
|
|
77
|
+
label: "Outside read",
|
|
78
|
+
description: "Reads outside the project boundary",
|
|
79
|
+
currentValue: modes.outRead,
|
|
80
|
+
values: [...OUT_VALUES],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "outWrite",
|
|
84
|
+
label: "Outside write / exec",
|
|
85
|
+
description: "Writes and execution outside the project boundary",
|
|
86
|
+
currentValue: modes.outWrite,
|
|
87
|
+
values: [...OUT_VALUES],
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Open the mode panel. Resolves with the chosen modes, or `undefined` when the
|
|
94
|
+
* operator cancels (Esc) or the host cannot render a custom component.
|
|
95
|
+
*/
|
|
96
|
+
export function openPermissionPanel(
|
|
97
|
+
ctx: ExtensionContext,
|
|
98
|
+
modes: PermissionModes,
|
|
99
|
+
): Promise<PermissionModes | undefined> {
|
|
100
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return Promise.resolve(undefined);
|
|
101
|
+
|
|
102
|
+
return ctx.ui.custom<PermissionModes | undefined>(
|
|
103
|
+
(_tui, _theme, _keybindings, done) => {
|
|
104
|
+
let current: PermissionModes = { ...modes };
|
|
105
|
+
const items = buildItems(current);
|
|
106
|
+
const list = new SettingsList(
|
|
107
|
+
items,
|
|
108
|
+
8,
|
|
109
|
+
getSettingsListTheme(),
|
|
110
|
+
(id, value) => {
|
|
111
|
+
current = { ...current, [id]: value } as PermissionModes;
|
|
112
|
+
},
|
|
113
|
+
() => done(undefined),
|
|
114
|
+
);
|
|
115
|
+
return new ApplyOnEnter(list, () => current, done);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path canonicalization, containment, grant scoping, and add-dir state.
|
|
3
|
+
*/
|
|
4
|
+
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
canonicalize,
|
|
10
|
+
expandHome,
|
|
11
|
+
grantRoot,
|
|
12
|
+
insideAnyRoot,
|
|
13
|
+
isWithin,
|
|
14
|
+
readAddedDirectories,
|
|
15
|
+
sessionRoots,
|
|
16
|
+
} from "./paths.js";
|
|
17
|
+
|
|
18
|
+
function tempProject(): string {
|
|
19
|
+
return realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-paths-")));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("expandHome", () => {
|
|
23
|
+
it("expands only a leading ~", () => {
|
|
24
|
+
expect(expandHome("~")).toBe(homedir());
|
|
25
|
+
expect(expandHome("~/x")).toBe(join(homedir(), "x"));
|
|
26
|
+
expect(expandHome("~user/x")).toBe("~user/x");
|
|
27
|
+
expect(expandHome("/abs/~/x")).toBe("/abs/~/x");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("containment", () => {
|
|
32
|
+
it("matches the root and its contents but not a sibling prefix", () => {
|
|
33
|
+
expect(isWithin("/a/b", "/a/b")).toBe(true);
|
|
34
|
+
expect(isWithin("/a/b", "/a/b/c")).toBe(true);
|
|
35
|
+
expect(isWithin("/a/b", "/a/bc")).toBe(false);
|
|
36
|
+
expect(isWithin("/a/b", "/a")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("resolves missing suffixes through the nearest existing ancestor", () => {
|
|
40
|
+
const root = tempProject();
|
|
41
|
+
expect(canonicalize(join(root, "missing", "deep", "file"))).toBe(
|
|
42
|
+
join(realpathSync(root), "missing", "deep", "file"),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("resolves symlinks, so a link cannot walk out of the root", () => {
|
|
47
|
+
const root = tempProject();
|
|
48
|
+
const outside = tempProject();
|
|
49
|
+
writeFileSync(join(outside, "secret"), "x");
|
|
50
|
+
symlinkSync(outside, join(root, "link"));
|
|
51
|
+
const resolved = canonicalize(join(root, "link", "secret"));
|
|
52
|
+
expect(isWithin(root, resolved)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("deduplicates and canonicalizes the session roots", () => {
|
|
56
|
+
const root = tempProject();
|
|
57
|
+
const outside = tempProject();
|
|
58
|
+
const roots = sessionRoots(root, [root, outside, outside]);
|
|
59
|
+
expect(roots).toHaveLength(2);
|
|
60
|
+
expect(insideAnyRoot(roots, join(outside, "x"))).toBe(true);
|
|
61
|
+
expect(insideAnyRoot(roots, "/definitely/not/here")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("grantRoot", () => {
|
|
66
|
+
it("uses the directory itself for a directory", () => {
|
|
67
|
+
const root = tempProject();
|
|
68
|
+
const dir = join(root, "build");
|
|
69
|
+
mkdirSync(dir);
|
|
70
|
+
expect(grantRoot(dir)).toBe(dir);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("uses the parent for an existing file", () => {
|
|
74
|
+
const root = tempProject();
|
|
75
|
+
const file = join(root, "notes.txt");
|
|
76
|
+
writeFileSync(file, "x");
|
|
77
|
+
expect(grantRoot(file)).toBe(root);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("uses the parent for a path that does not exist yet", () => {
|
|
81
|
+
const root = tempProject();
|
|
82
|
+
expect(grantRoot(join(root, "build"))).toBe(root);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("readAddedDirectories", () => {
|
|
87
|
+
it("takes the last add-dir:state entry and ignores other entries", () => {
|
|
88
|
+
const entries = [
|
|
89
|
+
{ type: "message" },
|
|
90
|
+
{ type: "custom", customType: "other", data: { dirs: [{ absolutePath: "/no" }] } },
|
|
91
|
+
{
|
|
92
|
+
type: "custom",
|
|
93
|
+
customType: "add-dir:state",
|
|
94
|
+
data: { dirs: [{ absolutePath: "/a" }, { absolutePath: "/b" }] },
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
type: "custom",
|
|
98
|
+
customType: "add-dir:state",
|
|
99
|
+
data: { dirs: [{ absolutePath: "/c" }] },
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
expect(readAddedDirectories(entries)).toEqual(["/c"]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns nothing without pi-add-dir state or with a malformed payload", () => {
|
|
106
|
+
expect(readAddedDirectories([{ type: "message" }])).toEqual([]);
|
|
107
|
+
expect(
|
|
108
|
+
readAddedDirectories([{ type: "custom", customType: "add-dir:state", data: {} }]),
|
|
109
|
+
).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permission feature — path resolution and containment.
|
|
3
|
+
*
|
|
4
|
+
* "Inside the project" means inside the session working directory or inside a
|
|
5
|
+
* directory added to the session (pi-add-dir stores those in a session entry).
|
|
6
|
+
* Containment is decided on canonical paths, so `../` and symlinks cannot walk
|
|
7
|
+
* the check out of a root; a path that does not exist yet is resolved through
|
|
8
|
+
* its nearest existing ancestor.
|
|
9
|
+
*/
|
|
10
|
+
import { realpathSync, statSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** Expand a leading `~` (and only a leading `~/` or bare `~`). */
|
|
15
|
+
export function expandHome(pathValue: string): string {
|
|
16
|
+
if (pathValue === "~") return homedir();
|
|
17
|
+
if (pathValue.startsWith(`~${sep}`) || pathValue.startsWith("~/")) {
|
|
18
|
+
return join(homedir(), pathValue.slice(2));
|
|
19
|
+
}
|
|
20
|
+
return pathValue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Absolute, symlink-resolved form of `pathValue`.
|
|
25
|
+
*
|
|
26
|
+
* The path itself may not exist (a write target, `mkdir` argument, …), so the
|
|
27
|
+
* nearest existing ancestor is resolved and the missing suffix re-appended.
|
|
28
|
+
*/
|
|
29
|
+
export function canonicalize(pathValue: string): string {
|
|
30
|
+
const absolute = resolve(pathValue);
|
|
31
|
+
const suffix: string[] = [];
|
|
32
|
+
let current = absolute;
|
|
33
|
+
for (;;) {
|
|
34
|
+
try {
|
|
35
|
+
const real = realpathSync.native(current);
|
|
36
|
+
return suffix.length === 0
|
|
37
|
+
? real
|
|
38
|
+
: join(real, ...suffix.reverse());
|
|
39
|
+
} catch {
|
|
40
|
+
const parent = dirname(current);
|
|
41
|
+
if (parent === current) return absolute;
|
|
42
|
+
suffix.push(basename(current));
|
|
43
|
+
current = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fold(value: string): string {
|
|
49
|
+
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether `pathValue` is `root` or under it. Both must be canonical. */
|
|
53
|
+
export function isWithin(root: string, pathValue: string): boolean {
|
|
54
|
+
const a = fold(root);
|
|
55
|
+
const b = fold(pathValue);
|
|
56
|
+
return b === a || b.startsWith(a.endsWith(sep) ? a : `${a}${sep}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The directory a session grant on `pathValue` should cover. */
|
|
60
|
+
export function grantRoot(pathValue: string): string {
|
|
61
|
+
try {
|
|
62
|
+
return statSync(pathValue).isDirectory() ? pathValue : dirname(pathValue);
|
|
63
|
+
} catch {
|
|
64
|
+
// A path that does not exist (a write target, a `mkdir` argument) is
|
|
65
|
+
// granted at its enclosing directory: the operator is approving an area,
|
|
66
|
+
// and a per-file grant would ask again for the next sibling.
|
|
67
|
+
return dirname(pathValue);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Structural shape of a session entry, so no host type is imported here. */
|
|
72
|
+
interface SessionEntryLike {
|
|
73
|
+
readonly type: string;
|
|
74
|
+
readonly customType?: string;
|
|
75
|
+
readonly data?: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface AddDirState {
|
|
79
|
+
readonly dirs?: ReadonlyArray<{ readonly absolutePath?: unknown }>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Directories added to the session by pi-add-dir.
|
|
84
|
+
*
|
|
85
|
+
* pi exposes no directory-list API; pi-add-dir records its state as a session
|
|
86
|
+
* custom entry (`add-dir:state`, appended on every change). The last such
|
|
87
|
+
* entry on the branch wins. Absence of pi-add-dir is a normal, silent miss —
|
|
88
|
+
* only the working directory is then in scope.
|
|
89
|
+
*/
|
|
90
|
+
export function readAddedDirectories(
|
|
91
|
+
entries: readonly SessionEntryLike[],
|
|
92
|
+
): string[] {
|
|
93
|
+
let dirs: string[] = [];
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (entry.type !== "custom" || entry.customType !== "add-dir:state") {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const state = entry.data as AddDirState | undefined;
|
|
99
|
+
if (!Array.isArray(state?.dirs)) continue;
|
|
100
|
+
dirs = state.dirs
|
|
101
|
+
.map((dir) =>
|
|
102
|
+
typeof dir?.absolutePath === "string" ? dir.absolutePath : "",
|
|
103
|
+
)
|
|
104
|
+
.filter((dir) => dir !== "");
|
|
105
|
+
}
|
|
106
|
+
return dirs;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Canonical root list for one session: working directory plus added dirs. */
|
|
110
|
+
export function sessionRoots(cwd: string, added: readonly string[]): string[] {
|
|
111
|
+
const roots = new Set<string>();
|
|
112
|
+
for (const value of [cwd, ...added]) {
|
|
113
|
+
if (!value) continue;
|
|
114
|
+
roots.add(canonicalize(expandHome(value)));
|
|
115
|
+
}
|
|
116
|
+
return [...roots];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `true` when `pathValue` is inside at least one root. */
|
|
120
|
+
export function insideAnyRoot(
|
|
121
|
+
roots: readonly string[],
|
|
122
|
+
pathValue: string,
|
|
123
|
+
): boolean {
|
|
124
|
+
return roots.some((root) => isWithin(root, pathValue));
|
|
125
|
+
}
|
|
126
|
+
|