portable-agent-layer 0.85.9 → 0.85.11

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,229 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { palHome, palPkg } from "../../hooks/lib/paths";
4
+ import { auditBindings, type BindingIssue } from "../../hooks/lib/projects";
5
+ import { telosStatus } from "../../hooks/lib/telos-topics";
6
+ import { type Finding, failing, optional, passed, warning } from "./finding";
7
+
8
+ interface SchemaNode {
9
+ type?: string;
10
+ properties?: Record<string, SchemaNode>;
11
+ additionalProperties?: boolean | SchemaNode;
12
+ items?: SchemaNode;
13
+ }
14
+
15
+ interface UnknownKey {
16
+ path: string;
17
+ suggestion?: string;
18
+ }
19
+
20
+ function editDistance(a: string, b: string): number {
21
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
22
+ for (let i = 1; i <= a.length; i++) {
23
+ let diagonal = row[0];
24
+ row[0] = i;
25
+ for (let j = 1; j <= b.length; j++) {
26
+ const above = row[j];
27
+ row[j] = Math.min(
28
+ row[j] + 1,
29
+ row[j - 1] + 1,
30
+ diagonal + (a[i - 1] === b[j - 1] ? 0 : 1)
31
+ );
32
+ diagonal = above;
33
+ }
34
+ }
35
+ return row[b.length];
36
+ }
37
+
38
+ function closest(key: string, candidates: string[]): string | undefined {
39
+ const ranked = candidates
40
+ .map((candidate) => ({ candidate, distance: editDistance(key, candidate) }))
41
+ .sort((x, y) => x.distance - y.distance);
42
+ const best = ranked[0];
43
+ return best && best.distance <= 2 ? best.candidate : undefined;
44
+ }
45
+
46
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === "object" && value !== null && !Array.isArray(value);
48
+ }
49
+
50
+ export function unknownSettingsKeys(
51
+ value: unknown,
52
+ schema: SchemaNode,
53
+ path = ""
54
+ ): UnknownKey[] {
55
+ if (Array.isArray(value) && schema.items) {
56
+ const items = schema.items;
57
+ return value.flatMap((item, i) => unknownSettingsKeys(item, items, `${path}[${i}]`));
58
+ }
59
+ if (!isPlainObject(value)) return [];
60
+ const declared = schema.properties ?? {};
61
+ const closed = schema.properties !== undefined && schema.additionalProperties !== true;
62
+ return Object.entries(value).flatMap(([key, child]) => {
63
+ const childPath = path ? `${path}.${key}` : key;
64
+ const childSchema = declared[key];
65
+ if (childSchema) return unknownSettingsKeys(child, childSchema, childPath);
66
+ if (!closed) return [];
67
+ return [{ path: childPath, suggestion: closest(key, Object.keys(declared)) }];
68
+ });
69
+ }
70
+
71
+ function settingsPath(): string {
72
+ return resolve(palHome(), "memory", "pal-settings.json");
73
+ }
74
+
75
+ function readSettingsSchema(): SchemaNode | null {
76
+ try {
77
+ return JSON.parse(
78
+ readFileSync(
79
+ resolve(palPkg(), "assets", "schema", "pal-settings.schema.json"),
80
+ "utf-8"
81
+ )
82
+ );
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function identityFinding(settings: Record<string, unknown>): Finding {
89
+ const identity = settings.identity as
90
+ | { principal?: { name?: string }; ai?: { name?: string } }
91
+ | undefined;
92
+ if (identity?.principal?.name && identity?.ai?.name)
93
+ return passed("identity", "Identity configured");
94
+ return warning("identity", "Identity is incomplete — PAL does not know both names", {
95
+ say: "Answer the identity questions",
96
+ command: "pal cli install",
97
+ });
98
+ }
99
+
100
+ function describeUnknownKey(key: UnknownKey): string {
101
+ return key.suggestion ? `${key.path} (did you mean ${key.suggestion}?)` : key.path;
102
+ }
103
+
104
+ function unknownKeysFinding(keys: UnknownKey[]): Finding {
105
+ if (keys.length === 0)
106
+ return passed("settings.unknown", "Every setting is one PAL reads");
107
+ const noun = keys.length === 1 ? "a setting" : `${keys.length} settings`;
108
+ return warning(
109
+ "settings.unknown",
110
+ `pal-settings.json has ${noun} PAL ignores: ${keys.map(describeUnknownKey).join(", ")}`,
111
+ { say: `Rename or remove them in ${settingsPath()}` }
112
+ );
113
+ }
114
+
115
+ function settingsFindings(): Finding[] {
116
+ const path = settingsPath();
117
+ if (!existsSync(path))
118
+ return [
119
+ warning("settings.missing", "pal-settings.json is missing", {
120
+ say: "Recreate it",
121
+ command: "pal cli install",
122
+ }),
123
+ ];
124
+ let settings: unknown;
125
+ try {
126
+ settings = JSON.parse(readFileSync(path, "utf-8"));
127
+ } catch (error) {
128
+ return [
129
+ failing(
130
+ "settings.unreadable",
131
+ `pal-settings.json is not valid JSON: ${(error as Error).message}`,
132
+ {
133
+ say: `Fix the JSON in ${path} — PAL is running on defaults until then`,
134
+ }
135
+ ),
136
+ ];
137
+ }
138
+ if (!isPlainObject(settings))
139
+ return [
140
+ failing("settings.unreadable", "pal-settings.json is not a JSON object", {
141
+ say: `Fix ${path}`,
142
+ }),
143
+ ];
144
+ const schema = readSettingsSchema();
145
+ const unknown = schema ? unknownSettingsKeys(settings, schema) : [];
146
+ return [identityFinding(settings), unknownKeysFinding(unknown)];
147
+ }
148
+
149
+ function telosFindings(home: string): Finding[] {
150
+ const files = (() => {
151
+ try {
152
+ return readdirSync(resolve(home, "telos")).filter((f) => f.endsWith(".md")).length;
153
+ } catch {
154
+ return 0;
155
+ }
156
+ })();
157
+ if (files === 0)
158
+ return [
159
+ failing("telos.missing", "TELOS is not scaffolded", {
160
+ say: "Scaffold it",
161
+ command: "pal cli init",
162
+ }),
163
+ ];
164
+ const unanswered = telosStatus(home)
165
+ .filter((topic) => topic.priority && !topic.answered)
166
+ .map((topic) => topic.key);
167
+ return [
168
+ passed("telos", `TELOS: ${files} files`),
169
+ unanswered.length === 0
170
+ ? passed("telos.answered", "TELOS answered")
171
+ : optional(
172
+ "telos.unanswered",
173
+ `TELOS ${unanswered.join(", ")} — lets PAL steer by your goals`,
174
+ { say: "ask your agent to onboard you" }
175
+ ),
176
+ ];
177
+ }
178
+
179
+ /** "Points at" rather than "bound to": the path may come from a legacy record's own field. */
180
+ function bindingProblem(issue: BindingIssue): { project: string; title: string } {
181
+ if (issue.kind === "unlocatable")
182
+ return {
183
+ project: issue.project,
184
+ title: `Project ${issue.project} is not checked out on this machine`,
185
+ };
186
+ if (issue.kind === "missing")
187
+ return {
188
+ project: issue.project,
189
+ title: `Project ${issue.project} points at ${issue.path}, which does not exist`,
190
+ };
191
+ return {
192
+ project: issue.projects[0],
193
+ title: `Projects ${issue.projects.join(" and ")} both point at ${issue.path} — rebind whichever is wrong`,
194
+ };
195
+ }
196
+
197
+ export function bindingFinding(issue: BindingIssue): Finding {
198
+ const { project, title } = bindingProblem(issue);
199
+ return warning(`binding.${project}`, title, {
200
+ say: "Point it at its checkout",
201
+ command: `pal cli project set-path ${project} <path>`,
202
+ });
203
+ }
204
+
205
+ function bindingFindings(): Finding[] {
206
+ const issues = auditBindings();
207
+ return issues.length === 0
208
+ ? [passed("bindings", "Project bindings healthy")]
209
+ : issues.map(bindingFinding);
210
+ }
211
+
212
+ function dependencyFinding(): Finding {
213
+ return existsSync(resolve(palPkg(), "node_modules"))
214
+ ? passed("dependencies", "Dependencies installed")
215
+ : failing("dependencies", "PAL's dependencies are not installed", {
216
+ say: "Install them",
217
+ command: "pal cli install",
218
+ });
219
+ }
220
+
221
+ export function stateFindings(): Finding[] {
222
+ const home = palHome();
223
+ return [
224
+ ...telosFindings(home),
225
+ ...settingsFindings(),
226
+ ...bindingFindings(),
227
+ dependencyFinding(),
228
+ ];
229
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The doctor's git and gh checks. Neither is required: without git, project
3
+ * matching by remote and the update checks skip; without gh, nothing does yet.
4
+ */
5
+
6
+ import { findBinaryOnPath } from "../../hooks/lib/which";
7
+ import { type Finding, type Fix, optional, passed, warning } from "./finding";
8
+ import { type LinuxFamily, linuxFamily, readOsRelease } from "./os-release";
9
+
10
+ interface Host {
11
+ platform: NodeJS.Platform;
12
+ osRelease: string;
13
+ timeoutMs: number;
14
+ }
15
+
16
+ const GH_LINUX_DOCS = "https://github.com/cli/cli/blob/trunk/docs/install_linux.md";
17
+
18
+ const GIT_ON_LINUX: Record<LinuxFamily, string> = {
19
+ debian: "sudo apt install git",
20
+ fedora: "sudo dnf install git",
21
+ arch: "sudo pacman -S git",
22
+ alpine: "sudo apk add git",
23
+ suse: "sudo zypper install git",
24
+ };
25
+
26
+ const GH_ON_LINUX: Partial<Record<LinuxFamily, string>> = {
27
+ fedora: "sudo dnf install gh",
28
+ arch: "sudo pacman -S github-cli",
29
+ };
30
+
31
+ export function gitInstallHint(platform: NodeJS.Platform, osRelease: string): Fix {
32
+ if (platform === "win32")
33
+ return { say: "Install git", command: "winget install Git.Git" };
34
+ if (platform === "darwin") return { say: "Install git", command: "brew install git" };
35
+ const family = linuxFamily(osRelease);
36
+ return family
37
+ ? { say: "Install git", command: GIT_ON_LINUX[family] }
38
+ : { say: "Install git with your package manager" };
39
+ }
40
+
41
+ export function ghInstallHint(platform: NodeJS.Platform, osRelease: string): Fix {
42
+ if (platform === "win32")
43
+ return { say: "Install gh", command: "winget install GitHub.cli" };
44
+ if (platform === "darwin") return { say: "Install gh", command: "brew install gh" };
45
+ const family = linuxFamily(osRelease);
46
+ const command = family ? GH_ON_LINUX[family] : undefined;
47
+ return command
48
+ ? { say: "Install gh", command }
49
+ : { say: `Install gh as described at ${GH_LINUX_DOCS}` };
50
+ }
51
+
52
+ function run(binary: string, args: string[], timeoutMs: number) {
53
+ const result = Bun.spawnSync([binary, ...args], {
54
+ stdout: "pipe",
55
+ stderr: "ignore",
56
+ timeout: timeoutMs,
57
+ windowsHide: true,
58
+ });
59
+ return {
60
+ answered: !result.exitedDueToTimeout,
61
+ ok: result.success,
62
+ firstLine: result.stdout.toString().trim().split("\n")[0] ?? "",
63
+ };
64
+ }
65
+
66
+ function installed(name: string, timeoutMs: number) {
67
+ const binary = findBinaryOnPath(name);
68
+ if (!binary) return null;
69
+ const version = run(binary, ["--version"], timeoutMs);
70
+ return version.ok ? { binary, version: version.firstLine } : null;
71
+ }
72
+
73
+ function gitFinding(host: Host): Finding {
74
+ const git = installed("git", host.timeoutMs);
75
+ if (git) return passed("git", git.version);
76
+ return warning(
77
+ "git.missing",
78
+ "git not found — project matching by remote, the update check and auto-update skip without it",
79
+ gitInstallHint(host.platform, host.osRelease)
80
+ );
81
+ }
82
+
83
+ function ghFinding(host: Host): Finding {
84
+ const gh = installed("gh", host.timeoutMs);
85
+ if (!gh)
86
+ return optional(
87
+ "gh.missing",
88
+ "gh — lets PAL see PR, CI and release state",
89
+ ghInstallHint(host.platform, host.osRelease)
90
+ );
91
+ const auth = run(gh.binary, ["auth", "status"], host.timeoutMs);
92
+ if (!auth.answered) return passed("gh", `${gh.version} — login not checked in time`);
93
+ if (!auth.ok)
94
+ return warning("gh.logged-out", `${gh.version} is not logged in`, {
95
+ say: "Log in",
96
+ command: "gh auth login",
97
+ });
98
+ return passed("gh", `${gh.version}, logged in`);
99
+ }
100
+
101
+ export function versionControlFindings(host: Partial<Host> = {}): Finding[] {
102
+ const resolved: Host = {
103
+ platform: host.platform ?? process.platform,
104
+ osRelease: host.osRelease ?? readOsRelease(),
105
+ timeoutMs: host.timeoutMs ?? 5000,
106
+ };
107
+ return [gitFinding(resolved), ghFinding(resolved)];
108
+ }