portable-agent-layer 0.85.7 → 0.85.9
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/package.json +1 -1
- package/src/cli/doctor-tools.ts +133 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/migrate.ts +5 -12
- package/src/hooks/lib/bindings.ts +2 -0
- package/src/hooks/lib/parked-history.ts +59 -0
- package/src/hooks/lib/projects.ts +5 -5
package/package.json
CHANGED
|
@@ -0,0 +1,133 @@
|
|
|
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 { readFileSync } from "node:fs";
|
|
7
|
+
import { findBinaryOnPath } from "../hooks/lib/which";
|
|
8
|
+
|
|
9
|
+
export interface DoctorLine {
|
|
10
|
+
level: "ok" | "warn" | "info";
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type LinuxFamily = "debian" | "fedora" | "arch" | "alpine" | "suse";
|
|
15
|
+
|
|
16
|
+
interface Host {
|
|
17
|
+
platform: NodeJS.Platform;
|
|
18
|
+
osRelease: string;
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const GH_LINUX_DOCS = "https://github.com/cli/cli/blob/trunk/docs/install_linux.md";
|
|
23
|
+
|
|
24
|
+
function osReleaseIds(osRelease: string): string[] {
|
|
25
|
+
const field = (key: string) =>
|
|
26
|
+
osRelease.match(new RegExp(String.raw`^${key}="?([^"\n]*)"?$`, "m"))?.[1] ?? "";
|
|
27
|
+
return `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/).filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function linuxFamily(osRelease: string): LinuxFamily | null {
|
|
31
|
+
const ids = osReleaseIds(osRelease);
|
|
32
|
+
const families: [LinuxFamily, string[]][] = [
|
|
33
|
+
["debian", ["debian", "ubuntu"]],
|
|
34
|
+
["fedora", ["fedora", "rhel", "centos"]],
|
|
35
|
+
["arch", ["arch"]],
|
|
36
|
+
["alpine", ["alpine"]],
|
|
37
|
+
["suse", ["suse", "opensuse"]],
|
|
38
|
+
];
|
|
39
|
+
return families.find(([, names]) => names.some((n) => ids.includes(n)))?.[0] ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const GIT_ON_LINUX: Record<LinuxFamily, string> = {
|
|
43
|
+
debian: "sudo apt install git",
|
|
44
|
+
fedora: "sudo dnf install git",
|
|
45
|
+
arch: "sudo pacman -S git",
|
|
46
|
+
alpine: "sudo apk add git",
|
|
47
|
+
suse: "sudo zypper install git",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const GH_ON_LINUX: Partial<Record<LinuxFamily, string>> = {
|
|
51
|
+
fedora: "sudo dnf install gh",
|
|
52
|
+
arch: "sudo pacman -S github-cli",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function gitInstallHint(platform: NodeJS.Platform, osRelease: string): string {
|
|
56
|
+
if (platform === "win32") return "`winget install Git.Git`";
|
|
57
|
+
if (platform === "darwin") return "`brew install git` or `xcode-select --install`";
|
|
58
|
+
const family = linuxFamily(osRelease);
|
|
59
|
+
return family ? `\`${GIT_ON_LINUX[family]}\`` : "install git with your package manager";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ghInstallHint(platform: NodeJS.Platform, osRelease: string): string {
|
|
63
|
+
if (platform === "win32") return "`winget install GitHub.cli`";
|
|
64
|
+
if (platform === "darwin") return "`brew install gh`";
|
|
65
|
+
const family = linuxFamily(osRelease);
|
|
66
|
+
const command = family && GH_ON_LINUX[family];
|
|
67
|
+
return command ? `\`${command}\`` : `see ${GH_LINUX_DOCS}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function run(binary: string, args: string[], timeoutMs: number) {
|
|
71
|
+
const result = Bun.spawnSync([binary, ...args], {
|
|
72
|
+
stdout: "pipe",
|
|
73
|
+
stderr: "ignore",
|
|
74
|
+
timeout: timeoutMs,
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
answered: !result.exitedDueToTimeout,
|
|
79
|
+
ok: result.success,
|
|
80
|
+
firstLine: result.stdout.toString().trim().split("\n")[0] ?? "",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function installed(name: string, timeoutMs: number) {
|
|
85
|
+
const binary = findBinaryOnPath(name);
|
|
86
|
+
if (!binary) return null;
|
|
87
|
+
const version = run(binary, ["--version"], timeoutMs);
|
|
88
|
+
return version.ok ? { binary, version: version.firstLine } : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function gitLine(host: Host): DoctorLine {
|
|
92
|
+
const git = installed("git", host.timeoutMs);
|
|
93
|
+
if (git) return { level: "ok", text: git.version };
|
|
94
|
+
return {
|
|
95
|
+
level: "warn",
|
|
96
|
+
text: `git — not found; project matching by remote, the update check and auto-update skip without it. ${gitInstallHint(host.platform, host.osRelease)}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ghLine(host: Host): DoctorLine {
|
|
101
|
+
const gh = installed("gh", host.timeoutMs);
|
|
102
|
+
if (!gh)
|
|
103
|
+
return {
|
|
104
|
+
level: "info",
|
|
105
|
+
text: `gh — not installed (optional; lets PAL see PR, CI and release state — ${ghInstallHint(host.platform, host.osRelease)})`,
|
|
106
|
+
};
|
|
107
|
+
const auth = run(gh.binary, ["auth", "status"], host.timeoutMs);
|
|
108
|
+
if (!auth.answered)
|
|
109
|
+
return { level: "info", text: `${gh.version} — could not check the login in time` };
|
|
110
|
+
if (!auth.ok)
|
|
111
|
+
return {
|
|
112
|
+
level: "warn",
|
|
113
|
+
text: `${gh.version} — not logged in; run \`gh auth login\``,
|
|
114
|
+
};
|
|
115
|
+
return { level: "ok", text: `${gh.version}, logged in` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readOsRelease(): string {
|
|
119
|
+
try {
|
|
120
|
+
return readFileSync("/etc/os-release", "utf-8");
|
|
121
|
+
} catch {
|
|
122
|
+
return "";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function versionControlLines(host: Partial<Host> = {}): DoctorLine[] {
|
|
127
|
+
const resolved: Host = {
|
|
128
|
+
platform: host.platform ?? process.platform,
|
|
129
|
+
osRelease: host.osRelease ?? (process.platform === "linux" ? readOsRelease() : ""),
|
|
130
|
+
timeoutMs: host.timeoutMs ?? 5000,
|
|
131
|
+
};
|
|
132
|
+
return [gitLine(resolved), ghLine(resolved)];
|
|
133
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { telosStatus } from "../hooks/lib/telos-topics";
|
|
|
56
56
|
import { findBinaryOnPath } from "../hooks/lib/which";
|
|
57
57
|
import { log } from "../targets/lib";
|
|
58
58
|
import { builtinToolVerbs, runBuiltinTool } from "./builtin-tools";
|
|
59
|
+
import { versionControlLines } from "./doctor-tools";
|
|
59
60
|
import { checkPendingMigrations } from "./migrate";
|
|
60
61
|
import { findSessionAgent, NO_SESSION_AGENT_MESSAGE } from "./session-agent";
|
|
61
62
|
|
|
@@ -842,6 +843,8 @@ function doctor(silent = false): DoctorResult {
|
|
|
842
843
|
: info(
|
|
843
844
|
`rtk — not installed (optional; enables Bash output compression — ${rtkInstallHint()})`
|
|
844
845
|
);
|
|
846
|
+
const print = { ok, warn, info };
|
|
847
|
+
for (const line of versionControlLines()) print[line.level](line.text);
|
|
845
848
|
|
|
846
849
|
console.log("");
|
|
847
850
|
log.info("PAL state");
|
package/src/cli/migrate.ts
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
existsSync,
|
|
15
|
-
mkdirSync,
|
|
16
15
|
readdirSync,
|
|
17
16
|
readFileSync,
|
|
18
17
|
renameSync,
|
|
@@ -22,7 +21,7 @@ import {
|
|
|
22
21
|
} from "node:fs";
|
|
23
22
|
import { basename, dirname, resolve } from "node:path";
|
|
24
23
|
import { readBindings } from "../hooks/lib/bindings";
|
|
25
|
-
import {
|
|
24
|
+
import { foldHistoryInto } from "../hooks/lib/parked-history";
|
|
26
25
|
import { palHome, paths } from "../hooks/lib/paths";
|
|
27
26
|
import {
|
|
28
27
|
legacyJsonToProgress,
|
|
@@ -55,6 +54,8 @@ interface Migration {
|
|
|
55
54
|
description: string;
|
|
56
55
|
check(): { pending: boolean; detail?: string };
|
|
57
56
|
run(dryRun?: boolean): MigrationResult;
|
|
57
|
+
/** Moves its sources rather than copying them, so there is nothing left to delete. */
|
|
58
|
+
movesSources?: true;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
// ── v1-projects: JSON progress files → ISA.md ─────────────────────
|
|
@@ -588,15 +589,6 @@ function destinationFor(item: StrandedHistory): string {
|
|
|
588
589
|
: resolve(paths.unboundHistory(), `${item.slug}.jsonl`);
|
|
589
590
|
}
|
|
590
591
|
|
|
591
|
-
/** Union both sides so a destination that already has history keeps it. */
|
|
592
|
-
function foldHistoryInto(target: string, source: string): number {
|
|
593
|
-
const local = existsSync(target) ? readFileSync(target, "utf-8") : "";
|
|
594
|
-
const { text, added } = mergeJsonlLines(local, readFileSync(source, "utf-8"));
|
|
595
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
596
|
-
writeFileSync(target, text, "utf-8");
|
|
597
|
-
return added;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
592
|
function discardEmptyFolder(dir: string): void {
|
|
601
593
|
try {
|
|
602
594
|
if (readdirSync(dir).length === 0) rmdirSync(dir);
|
|
@@ -608,6 +600,7 @@ function discardEmptyFolder(dir: string): void {
|
|
|
608
600
|
const v6HistorySlugs: Migration = {
|
|
609
601
|
id: "v6-history-slugs",
|
|
610
602
|
description: "Re-file session history keyed on a cwd name onto its owning project",
|
|
603
|
+
movesSources: true,
|
|
611
604
|
|
|
612
605
|
check() {
|
|
613
606
|
const stranded = strandedHistory();
|
|
@@ -728,7 +721,7 @@ export function runMigrate(args: string[]): void {
|
|
|
728
721
|
console.log(
|
|
729
722
|
` ${dryRun ? "Would migrate" : "Migrated"}: ${totalMigrated} | Skipped: ${totalSkipped}`
|
|
730
723
|
);
|
|
731
|
-
if (!dryRun && totalMigrated > 0) {
|
|
724
|
+
if (!dryRun && totalMigrated > 0 && pending.some((m) => !m.movesSources)) {
|
|
732
725
|
console.log(" Source files preserved — delete manually once verified.");
|
|
733
726
|
}
|
|
734
727
|
console.log("");
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
import { resolve } from "node:path";
|
|
23
|
+
import { adoptParkedHistory } from "./parked-history";
|
|
23
24
|
import { palHome, toPath } from "./paths";
|
|
24
25
|
|
|
25
26
|
/** Project name → absolute path on this machine. */
|
|
@@ -91,6 +92,7 @@ export function writeBindings(bindings: Bindings, home: string = palHome()): voi
|
|
|
91
92
|
const sorted: Bindings = {};
|
|
92
93
|
for (const key of Object.keys(bindings).sort()) sorted[key] = bindings[key];
|
|
93
94
|
writeFileSync(bindingsFilePath(home), `${JSON.stringify(sorted, null, 2)}\n`);
|
|
95
|
+
adoptParkedHistory(sorted, home);
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
/** The absolute path this machine has for `project`, or null when unbound. */
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History from a directory no project claimed is parked under the directory's
|
|
3
|
+
* name. Once exactly one project is bound to a directory of that name, the
|
|
4
|
+
* parked history is its own and moves in.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { basename, dirname, resolve } from "node:path";
|
|
16
|
+
import { mergeJsonlLines } from "./import-merge";
|
|
17
|
+
import { logError } from "./log";
|
|
18
|
+
|
|
19
|
+
type Bindings = Record<string, string>;
|
|
20
|
+
|
|
21
|
+
/** Union both sides so a destination that already has history keeps it. */
|
|
22
|
+
export function foldHistoryInto(target: string, source: string): number {
|
|
23
|
+
const local = existsSync(target) ? readFileSync(target, "utf-8") : "";
|
|
24
|
+
const { text, added } = mergeJsonlLines(local, readFileSync(source, "utf-8"));
|
|
25
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
26
|
+
writeFileSync(target, text, "utf-8");
|
|
27
|
+
return added;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The project bound to a directory named `slug`, when exactly one is. */
|
|
31
|
+
function projectBoundUnder(slug: string, bindings: Bindings): string | null {
|
|
32
|
+
const owners = Object.entries(bindings).filter(([, path]) => basename(path) === slug);
|
|
33
|
+
return owners.length === 1 ? owners[0][0] : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parkedDir(home: string): string {
|
|
37
|
+
return resolve(home, "memory", "state", "unbound-history");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function recordedProjectDir(home: string, project: string): string | null {
|
|
41
|
+
const dir = resolve(home, "memory", "projects", project);
|
|
42
|
+
return existsSync(resolve(dir, "ISA.md")) ? dir : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function adoptParkedHistory(bindings: Bindings, home: string): void {
|
|
46
|
+
const dir = parkedDir(home);
|
|
47
|
+
if (!existsSync(dir)) return;
|
|
48
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".jsonl"))) {
|
|
49
|
+
const owner = projectBoundUnder(file.slice(0, -".jsonl".length), bindings);
|
|
50
|
+
const projectDir = owner && recordedProjectDir(home, owner);
|
|
51
|
+
if (!projectDir) continue;
|
|
52
|
+
try {
|
|
53
|
+
foldHistoryInto(resolve(projectDir, "history.jsonl"), resolve(dir, file));
|
|
54
|
+
unlinkSync(resolve(dir, file));
|
|
55
|
+
} catch (err) {
|
|
56
|
+
logError("parked-history:adopt", err);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -267,11 +267,6 @@ export function writeProject(p: ProjectProgress): void {
|
|
|
267
267
|
const detected = detectRemote(p.path);
|
|
268
268
|
if (detected) p.remote = detected;
|
|
269
269
|
}
|
|
270
|
-
// Only a path that exists here may be bound. Saving a record is not a claim
|
|
271
|
-
// about this machine's disk — `path` may have arrived from an imported record
|
|
272
|
-
// written elsewhere, and binding it would recreate the very leak bindings exist
|
|
273
|
-
// to prevent. An explicit `writeBinding` from the user stays unguarded.
|
|
274
|
-
if (p.path && existsSync(p.path)) writeBinding(p.name, p.path);
|
|
275
270
|
const meta: Record<string, unknown> = {
|
|
276
271
|
name: p.name,
|
|
277
272
|
status: p.status,
|
|
@@ -290,6 +285,11 @@ export function writeProject(p: ProjectProgress): void {
|
|
|
290
285
|
if (p.placed) meta.placed = p.placed;
|
|
291
286
|
if (p.placed_by) meta.placed_by = p.placed_by;
|
|
292
287
|
writeFileSync(ensureAndGetIsaFile(p.name), stringify(meta, buildBody(p)), "utf-8");
|
|
288
|
+
// Only a path that exists here may be bound. Saving a record is not a claim
|
|
289
|
+
// about this machine's disk — `path` may have arrived from an imported record
|
|
290
|
+
// written elsewhere, and binding it would recreate the very leak bindings exist
|
|
291
|
+
// to prevent. An explicit `writeBinding` from the user stays unguarded.
|
|
292
|
+
if (p.path && existsSync(p.path)) writeBinding(p.name, p.path);
|
|
293
293
|
}
|
|
294
294
|
|
|
295
295
|
export function deleteProject(name: string): boolean {
|