agent-yes 1.300.3 → 1.300.4
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/{SUPPORTED_CLIS-CWJr4TJH.js → SUPPORTED_CLIS-BIg_X-jE.js} +2 -2
- package/dist/{SUPPORTED_CLIS-ChSO8FiS.js → SUPPORTED_CLIS-D7lOxOnw.js} +3 -3
- package/dist/{agentShare-NkW0b0mg.js → agentShare-BfHUZgk4.js} +4 -4
- package/dist/{callback-CjlaFXp1.js → callback-COR0F2eB.js} +3 -3
- package/dist/{callback-B8CUyag4.js → callback-yWhCmi4k.js} +3 -3
- package/dist/{channels-aYwLMbFV.js → channels-rhxQArqN.js} +2 -2
- package/dist/cli.js +5 -5
- package/dist/{cmdPs-C8DIl8V6.js → cmdPs-B3jTE4f3.js} +3 -3
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-DWIfzEE7.js → notifyDaemon-BIo7Nkk2.js} +3 -3
- package/dist/{parentPingLoop-Bh3KuFcW.js → parentPingLoop-9T4Pzp6c.js} +2 -2
- package/dist/{remotes-B4v0Nowo.js → remotes-B4CNXwDS.js} +2 -2
- package/dist/{remotes-DnMQWaqQ.js → remotes-CQTEY8Gt.js} +1 -1
- package/dist/{rustBinary-gI_r3g9K.js → rustBinary-CJXUPUWd.js} +48 -5
- package/dist/rustBinary-FhGG3Yfl.js +4 -0
- package/dist/{schedule-DryC--yx.js → schedule-DmijwnTA.js} +4 -4
- package/dist/{serve-DVoYVgBo.js → serve-F5tXYO0-.js} +99 -32
- package/dist/{serveRust-DDQiZmMf.js → serveRust-BAawe83i.js} +16 -2
- package/dist/{setup--1zRHaGW.js → setup-mCb1JbTX.js} +2 -2
- package/dist/{share-C9oGvjXT.js → share-CLataFnI.js} +18 -12
- package/dist/share-nmgSIjYP.js +4 -0
- package/dist/{shareCmd-BI3hRWGO.js → shareCmd-aOKimJeT.js} +25 -7
- package/dist/{subcommands-BviFOQZE.js → subcommands-DRq0MgSm.js} +2 -2
- package/dist/{subcommands-UWLkF5pq.js → subcommands-Ddo2TYZs.js} +404 -77
- package/dist/{terminal-DviH1E3C.js → terminal-DEXrWOa9.js} +2 -2
- package/dist/trayApp-0RVjo43f.js +5 -0
- package/dist/{trayApp-PJrwfyDs.js → trayApp-BUXx7cES.js} +2 -2
- package/dist/{ts--JmSY117.js → ts-CRbycTni.js} +5 -5
- package/dist/{versionChecker-CCGSbjio.js → versionChecker-DeQEhRGB.js} +2 -2
- package/dist/{webrtcRemote-Cd4vTrgv.js → webrtcRemote-Cy5kB8aR.js} +115 -14
- package/dist/{widget-DgBDIGRQ.js → widget-DneXqnL8.js} +3 -3
- package/dist/{ws-DnBg8-g-.js → ws-DPw1lVTi.js} +3 -3
- package/dist/{ws-dc7MP054.js → ws-Dr-0Ckq7.js} +2 -2
- package/package.json +1 -1
- package/ts/remoteCache.bun.spec.ts +191 -0
- package/ts/remoteCache.spec.ts +191 -0
- package/ts/remoteCache.ts +193 -0
- package/ts/rustBinary.bun.spec.ts +39 -2
- package/ts/rustBinary.spec.ts +53 -2
- package/ts/rustBinary.ts +61 -3
- package/ts/serve.spec.ts +103 -0
- package/ts/serve.ts +100 -15
- package/ts/serveRust.spec.ts +24 -1
- package/ts/serveRust.ts +19 -0
- package/ts/share.bun.spec.ts +54 -2
- package/ts/share.spec.ts +54 -2
- package/ts/share.ts +40 -15
- package/ts/shareCmd.ts +33 -2
- package/ts/strayServe.ts +1 -0
- package/ts/subcommands.spec.ts +36 -3
- package/ts/subcommands.ts +384 -98
- package/ts/webrtcRemote.ts +66 -20
- package/ts/webrtcSignal.bun.spec.ts +120 -0
- package/ts/webrtcSignal.spec.ts +120 -0
- package/ts/webrtcSignal.ts +143 -0
- package/dist/rustBinary-BNmAJEBb.js +0 -4
- package/dist/share-Bmnd_qT0.js +0 -4
- package/dist/trayApp-BDIb8fOj.js +0 -5
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtemp, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import {
|
|
6
|
+
cacheAgeLabel,
|
|
7
|
+
hhmm,
|
|
8
|
+
humanizeAge,
|
|
9
|
+
isStale,
|
|
10
|
+
jsonRemote,
|
|
11
|
+
noteSnapshotResult,
|
|
12
|
+
parseWaitFor,
|
|
13
|
+
readRemoteSnapshot,
|
|
14
|
+
remoteCachePath,
|
|
15
|
+
writeRemoteSnapshot,
|
|
16
|
+
type RemoteSnapshot,
|
|
17
|
+
} from "./remoteCache.ts";
|
|
18
|
+
|
|
19
|
+
const T = 1_800_000_000_000;
|
|
20
|
+
const rows = [{ pid: 1111, cli: "claude", status: "active" }];
|
|
21
|
+
const good: RemoteSnapshot = {
|
|
22
|
+
version: 1,
|
|
23
|
+
alias: "alpha",
|
|
24
|
+
fetchedAt: T,
|
|
25
|
+
records: rows,
|
|
26
|
+
error: null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe("noteSnapshotResult", () => {
|
|
30
|
+
it("a success replaces the rows, stamps fetchedAt and clears the error", () => {
|
|
31
|
+
const prev: RemoteSnapshot = {
|
|
32
|
+
...good,
|
|
33
|
+
fetchedAt: T - 60_000,
|
|
34
|
+
records: [],
|
|
35
|
+
error: { message: "x", since: T - 30_000, lastTriedAt: T - 1 },
|
|
36
|
+
};
|
|
37
|
+
expect(noteSnapshotResult(prev, "alpha", { ok: true, records: rows }, T)).toEqual(good);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("a failure keeps the last good rows and preserves when the streak began", () => {
|
|
41
|
+
const first = noteSnapshotResult(good, "alpha", { ok: false, message: "timeout" }, T + 1000);
|
|
42
|
+
expect(first.records).toBe(rows);
|
|
43
|
+
expect(first.fetchedAt).toBe(T);
|
|
44
|
+
expect(first.error).toEqual({ message: "timeout", since: T + 1000, lastTriedAt: T + 1000 });
|
|
45
|
+
const second = noteSnapshotResult(first, "alpha", { ok: false, message: "refused" }, T + 5000);
|
|
46
|
+
expect(second.error).toEqual({ message: "refused", since: T + 1000, lastTriedAt: T + 5000 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("a first-ever failure has no rows and starts the streak now", () => {
|
|
50
|
+
const s = noteSnapshotResult(null, "beta", { ok: false, message: "dns" }, T);
|
|
51
|
+
expect(s).toEqual({
|
|
52
|
+
version: 1,
|
|
53
|
+
alias: "beta",
|
|
54
|
+
fetchedAt: null,
|
|
55
|
+
records: [],
|
|
56
|
+
error: { message: "dns", since: T, lastTriedAt: T },
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("isStale", () => {
|
|
62
|
+
it("treats no snapshot, never-fetched, old, and clock-backwards as stale", () => {
|
|
63
|
+
expect(isStale(null, T)).toBe(true);
|
|
64
|
+
expect(isStale({ ...good, fetchedAt: null }, T)).toBe(true);
|
|
65
|
+
expect(isStale(good, T + 30_000)).toBe(true);
|
|
66
|
+
expect(isStale(good, T - 1)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
it("serves fresh rows as-is", () => {
|
|
69
|
+
expect(isStale(good, T + 29_999)).toBe(false);
|
|
70
|
+
expect(isStale(good, T + 5_000, 1_000)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("labels", () => {
|
|
75
|
+
it("humanizeAge keeps the table vocabulary", () => {
|
|
76
|
+
expect(humanizeAge(500)).toBe("0s");
|
|
77
|
+
expect(humanizeAge(59_000)).toBe("59s");
|
|
78
|
+
expect(humanizeAge(60_000)).toBe("1m");
|
|
79
|
+
expect(humanizeAge(3_600_000)).toBe("1h");
|
|
80
|
+
expect(humanizeAge(2 * 86_400_000)).toBe("2d");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("hhmm is zero-padded local wall-clock", () => {
|
|
84
|
+
const d = new Date(2026, 0, 1, 9, 5);
|
|
85
|
+
expect(hhmm(d.getTime())).toBe("09:05");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("cacheAgeLabel says where the rows came from and never hides an outage", () => {
|
|
89
|
+
expect(cacheAgeLabel(good, T + 180_000, true)).toBe("cached 3m ago, refreshing…");
|
|
90
|
+
expect(cacheAgeLabel(good, T + 180_000, false)).toBe("cached 3m ago");
|
|
91
|
+
expect(cacheAgeLabel(null, T, true)).toBe("no cache yet, refreshing…");
|
|
92
|
+
const since = new Date(2026, 0, 1, 14, 2).getTime();
|
|
93
|
+
const down: RemoteSnapshot = { ...good, error: { message: "x", since, lastTriedAt: since } };
|
|
94
|
+
expect(cacheAgeLabel(down, T + 720_000, true)).toBe("cached 12m ago, unreachable since 14:02");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("parseWaitFor", () => {
|
|
99
|
+
const aliases = ["alpha", "beta"];
|
|
100
|
+
it("expands all / local, and accepts a comma list of configured aliases", () => {
|
|
101
|
+
expect(parseWaitFor("all", aliases)).toEqual({ local: true, remotes: ["alpha", "beta"] });
|
|
102
|
+
expect(parseWaitFor("local", aliases)).toEqual({ local: true, remotes: [] });
|
|
103
|
+
expect(parseWaitFor("beta", aliases)).toEqual({ local: false, remotes: ["beta"] });
|
|
104
|
+
expect(parseWaitFor(" alpha, beta ,alpha", aliases)).toEqual({
|
|
105
|
+
local: false,
|
|
106
|
+
remotes: ["alpha", "beta"],
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
it("refuses an unknown alias or an empty value rather than waiting for nothing", () => {
|
|
110
|
+
expect(parseWaitFor("gamma", aliases)).toMatchObject({
|
|
111
|
+
error: expect.stringContaining("gamma"),
|
|
112
|
+
});
|
|
113
|
+
expect(parseWaitFor("", aliases)).toMatchObject({
|
|
114
|
+
error: expect.stringContaining("--waitFor"),
|
|
115
|
+
});
|
|
116
|
+
expect(parseWaitFor("x", [])).toMatchObject({ error: expect.stringContaining("none") });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("jsonRemote", () => {
|
|
121
|
+
it("exposes fetchedAt, stale and age so a parser can decide", () => {
|
|
122
|
+
expect(jsonRemote(good, T + 1000)).toEqual({
|
|
123
|
+
fetchedAt: T,
|
|
124
|
+
stale: false,
|
|
125
|
+
ageMs: 1000,
|
|
126
|
+
error: null,
|
|
127
|
+
records: rows,
|
|
128
|
+
});
|
|
129
|
+
expect(jsonRemote(null, T)).toEqual({
|
|
130
|
+
fetchedAt: null,
|
|
131
|
+
stale: true,
|
|
132
|
+
ageMs: null,
|
|
133
|
+
error: null,
|
|
134
|
+
records: [],
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("snapshot files", () => {
|
|
140
|
+
let home: string;
|
|
141
|
+
const prev = process.env.AGENT_YES_HOME;
|
|
142
|
+
beforeEach(async () => {
|
|
143
|
+
home = await mkdtemp(path.join(tmpdir(), "ay-remote-cache-"));
|
|
144
|
+
process.env.AGENT_YES_HOME = home;
|
|
145
|
+
});
|
|
146
|
+
afterEach(async () => {
|
|
147
|
+
if (prev === undefined) delete process.env.AGENT_YES_HOME;
|
|
148
|
+
else process.env.AGENT_YES_HOME = prev;
|
|
149
|
+
await rm(home, { recursive: true, force: true });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("round-trips, encodes an unsafe alias into the file name, and leaves no temp file", async () => {
|
|
153
|
+
const snap: RemoteSnapshot = { ...good, alias: "a/b c" };
|
|
154
|
+
await writeRemoteSnapshot(snap);
|
|
155
|
+
expect(remoteCachePath("a/b c")).toBe(path.join(home, "remotes", "a%2Fb%20c.cache.json"));
|
|
156
|
+
expect(await readRemoteSnapshot("a/b c")).toEqual(snap);
|
|
157
|
+
expect(await readdir(path.join(home, "remotes"))).toEqual(["a%2Fb%20c.cache.json"]);
|
|
158
|
+
expect(JSON.parse(await readFile(remoteCachePath("a/b c"), "utf-8")).version).toBe(1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("reads a missing, corrupt, or foreign file as a cold cache", async () => {
|
|
162
|
+
expect(await readRemoteSnapshot("none")).toBeNull();
|
|
163
|
+
await writeRemoteSnapshot(good);
|
|
164
|
+
await writeFile(remoteCachePath("alpha"), "{not json");
|
|
165
|
+
expect(await readRemoteSnapshot("alpha")).toBeNull();
|
|
166
|
+
await writeFile(remoteCachePath("alpha"), JSON.stringify({ version: 2, records: [] }));
|
|
167
|
+
expect(await readRemoteSnapshot("alpha")).toBeNull();
|
|
168
|
+
await writeFile(remoteCachePath("alpha"), JSON.stringify([1, 2]));
|
|
169
|
+
expect(await readRemoteSnapshot("alpha")).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("tolerates a half-valid error object and a bad fetchedAt", async () => {
|
|
173
|
+
await writeFile(
|
|
174
|
+
remoteCachePath("alpha"),
|
|
175
|
+
JSON.stringify({ version: 1, fetchedAt: "soon", records: rows, error: { message: "x" } }),
|
|
176
|
+
).catch(async () => {
|
|
177
|
+
await writeRemoteSnapshot(good); // ensure the dir exists, then overwrite
|
|
178
|
+
await writeFile(
|
|
179
|
+
remoteCachePath("alpha"),
|
|
180
|
+
JSON.stringify({ version: 1, fetchedAt: "soon", records: rows, error: { message: "x" } }),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
expect(await readRemoteSnapshot("alpha")).toEqual({
|
|
184
|
+
version: 1,
|
|
185
|
+
alias: "alpha",
|
|
186
|
+
fetchedAt: null,
|
|
187
|
+
records: rows,
|
|
188
|
+
error: null,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { agentYesHome } from "./agentYesHome.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cached-first `ay ls` across remotes.
|
|
7
|
+
*
|
|
8
|
+
* Every `ay ls` used to probe every configured remote before printing a single
|
|
9
|
+
* row, so one slow or unreachable machine (a WebRTC remote costs up to 25 s to
|
|
10
|
+
* give up on) held the whole fleet view hostage. The listing is read far more
|
|
11
|
+
* often than the fleet changes, so the trade is clear: print the LAST KNOWN
|
|
12
|
+
* rows for each remote at once, say how old they are, and refresh in the
|
|
13
|
+
* background so the next call is fresh. Nothing stale may ever look live —
|
|
14
|
+
* every cached row and section carries its age — and `--waitFor` is the
|
|
15
|
+
* explicit way to pay for fresh data when a decision needs it.
|
|
16
|
+
*
|
|
17
|
+
* One snapshot file per alias under `$AGENT_YES_HOME/remotes/`, beside the
|
|
18
|
+
* health file (ts/remoteHealth.ts) and like it derived, disposable data:
|
|
19
|
+
* deleting one costs a single "no cache yet" listing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The current failure streak of a remote, kept alongside its last good rows. */
|
|
23
|
+
export interface RemoteCacheError {
|
|
24
|
+
message: string;
|
|
25
|
+
/** When the current run of failures began (unchanged by later failures). */
|
|
26
|
+
since: number;
|
|
27
|
+
lastTriedAt: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RemoteSnapshot {
|
|
31
|
+
version: 1;
|
|
32
|
+
alias: string;
|
|
33
|
+
/** Time of the last SUCCESSFUL fetch; null when the remote has never answered. */
|
|
34
|
+
fetchedAt: number | null;
|
|
35
|
+
/** The `/api/ls` rows from that fetch (kept across later failures). */
|
|
36
|
+
records: any[];
|
|
37
|
+
error: RemoteCacheError | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Rows younger than this are served as-is without kicking a refresh. */
|
|
41
|
+
export const REMOTE_CACHE_FRESH_MS = 30_000;
|
|
42
|
+
|
|
43
|
+
export function remoteCachePath(alias: string): string {
|
|
44
|
+
return path.join(agentYesHome(), "remotes", `${encodeURIComponent(alias)}.cache.json`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The persisted snapshot, or null for missing/corrupt — a cold cache, never an error. */
|
|
48
|
+
export async function readRemoteSnapshot(alias: string): Promise<RemoteSnapshot | null> {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(await readFile(remoteCachePath(alias), "utf-8")) as unknown;
|
|
51
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
52
|
+
const s = parsed as Partial<RemoteSnapshot>;
|
|
53
|
+
if (s.version !== 1 || !Array.isArray(s.records)) return null;
|
|
54
|
+
const fetchedAt =
|
|
55
|
+
typeof s.fetchedAt === "number" && Number.isFinite(s.fetchedAt) ? s.fetchedAt : null;
|
|
56
|
+
const e = s.error;
|
|
57
|
+
const error =
|
|
58
|
+
e && typeof e === "object" && typeof e.message === "string" && typeof e.since === "number"
|
|
59
|
+
? { message: e.message, since: e.since, lastTriedAt: Number(e.lastTriedAt) || e.since }
|
|
60
|
+
: null;
|
|
61
|
+
return { version: 1, alias, fetchedAt, records: s.records, error };
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** tmp + rename, like the health file: a concurrent reader never sees a torn file. */
|
|
68
|
+
export async function writeRemoteSnapshot(snap: RemoteSnapshot): Promise<void> {
|
|
69
|
+
const dest = remoteCachePath(snap.alias);
|
|
70
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
71
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
72
|
+
await writeFile(tmp, JSON.stringify(snap));
|
|
73
|
+
await rename(tmp, dest);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type FetchOutcome = { ok: true; records: any[] } | { ok: false; message: string };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fold one fetch into the snapshot. Success replaces the rows and clears the
|
|
80
|
+
* error; failure keeps the last good rows and extends the streak, preserving
|
|
81
|
+
* `since` so the operator sees "unreachable since 14:02", not "since now".
|
|
82
|
+
*/
|
|
83
|
+
export function noteSnapshotResult(
|
|
84
|
+
prev: RemoteSnapshot | null,
|
|
85
|
+
alias: string,
|
|
86
|
+
result: FetchOutcome,
|
|
87
|
+
now: number,
|
|
88
|
+
): RemoteSnapshot {
|
|
89
|
+
if (result.ok) return { version: 1, alias, fetchedAt: now, records: result.records, error: null };
|
|
90
|
+
return {
|
|
91
|
+
version: 1,
|
|
92
|
+
alias,
|
|
93
|
+
fetchedAt: prev?.fetchedAt ?? null,
|
|
94
|
+
records: prev?.records ?? [],
|
|
95
|
+
error: { message: result.message, since: prev?.error?.since ?? now, lastTriedAt: now },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether the rows are old enough to warrant a refresh. Fails open to "stale". */
|
|
100
|
+
export function isStale(
|
|
101
|
+
snap: RemoteSnapshot | null,
|
|
102
|
+
now: number,
|
|
103
|
+
freshMs = REMOTE_CACHE_FRESH_MS,
|
|
104
|
+
): boolean {
|
|
105
|
+
if (!snap || snap.fetchedAt === null) return true;
|
|
106
|
+
const age = now - snap.fetchedAt;
|
|
107
|
+
return age < 0 || age >= freshMs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** "0s", "42s", "3m", "5h", "2d" — the table's AGE column vocabulary. */
|
|
111
|
+
export function humanizeAge(ms: number): string {
|
|
112
|
+
if (ms < 1000) return "0s";
|
|
113
|
+
const s = Math.floor(ms / 1000);
|
|
114
|
+
if (s < 60) return `${s}s`;
|
|
115
|
+
const m = Math.floor(s / 60);
|
|
116
|
+
if (m < 60) return `${m}m`;
|
|
117
|
+
const h = Math.floor(m / 60);
|
|
118
|
+
if (h < 24) return `${h}h`;
|
|
119
|
+
const d = Math.floor(h / 24);
|
|
120
|
+
return `${d}d`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Local wall-clock HH:MM, for "unreachable since 14:02". */
|
|
124
|
+
export function hhmm(ts: number): string {
|
|
125
|
+
const d = new Date(ts);
|
|
126
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The per-remote tag printed beside its section: where the rows came from and
|
|
131
|
+
* how old they are. `refreshing` says a background refresh was just kicked.
|
|
132
|
+
*/
|
|
133
|
+
export function cacheAgeLabel(
|
|
134
|
+
snap: RemoteSnapshot | null,
|
|
135
|
+
now: number,
|
|
136
|
+
refreshing: boolean,
|
|
137
|
+
): string {
|
|
138
|
+
const base =
|
|
139
|
+
snap && snap.fetchedAt !== null
|
|
140
|
+
? `cached ${humanizeAge(now - snap.fetchedAt)} ago`
|
|
141
|
+
: "no cache yet";
|
|
142
|
+
if (snap?.error) return `${base}, unreachable since ${hhmm(snap.error.since)}`;
|
|
143
|
+
return refreshing ? `${base}, refreshing…` : base;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** What `--waitFor` asked to block on. */
|
|
147
|
+
export interface WaitFor {
|
|
148
|
+
local: boolean;
|
|
149
|
+
/** Configured aliases to refresh in the foreground before printing. */
|
|
150
|
+
remotes: string[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `--waitFor=all|local|<alias>[,<alias>…]`. Unknown aliases are an error
|
|
155
|
+
* rather than silently waited-for-nothing: the flag exists so a caller can
|
|
156
|
+
* trust the rows it gets, and a typo must not become a false "fresh".
|
|
157
|
+
*/
|
|
158
|
+
export function parseWaitFor(value: string, aliases: string[]): WaitFor | { error: string } {
|
|
159
|
+
const v = value.trim();
|
|
160
|
+
if (!v) return { error: "--waitFor needs a value: all, local, or an alias list" };
|
|
161
|
+
if (v === "all") return { local: true, remotes: [...aliases] };
|
|
162
|
+
if (v === "local") return { local: true, remotes: [] };
|
|
163
|
+
const want = v
|
|
164
|
+
.split(",")
|
|
165
|
+
.map((s) => s.trim())
|
|
166
|
+
.filter(Boolean);
|
|
167
|
+
const unknown = want.filter((a) => !aliases.includes(a));
|
|
168
|
+
if (unknown.length)
|
|
169
|
+
return {
|
|
170
|
+
error: `--waitFor: unknown remote ${unknown.join(", ")} (configured: ${aliases.join(", ") || "none"})`,
|
|
171
|
+
};
|
|
172
|
+
return { local: false, remotes: [...new Set(want)] };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The `--json` shape for one remote: additive, so a parser can decide on staleness. */
|
|
176
|
+
export function jsonRemote(
|
|
177
|
+
snap: RemoteSnapshot | null,
|
|
178
|
+
now: number,
|
|
179
|
+
): {
|
|
180
|
+
fetchedAt: number | null;
|
|
181
|
+
stale: boolean;
|
|
182
|
+
ageMs: number | null;
|
|
183
|
+
error: RemoteCacheError | null;
|
|
184
|
+
records: any[];
|
|
185
|
+
} {
|
|
186
|
+
return {
|
|
187
|
+
fetchedAt: snap?.fetchedAt ?? null,
|
|
188
|
+
stale: isStale(snap, now),
|
|
189
|
+
ageMs: snap && snap.fetchedAt !== null ? now - snap.fetchedAt : null,
|
|
190
|
+
error: snap?.error ?? null,
|
|
191
|
+
records: snap?.records ?? [],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { gcOldBinaryDirs } from "./rustBinary.ts";
|
|
5
|
+
import { chmodExtractedBinaries, ensureExecutable, gcOldBinaryDirs } from "./rustBinary.ts";
|
|
6
6
|
import { _setInstalledPackageForTesting } from "./versionChecker.ts";
|
|
7
7
|
|
|
8
8
|
// Startup GC for the versioned binary download cache (PERFORMANCE-EVENT
|
|
@@ -79,3 +79,40 @@ describe("gcOldBinaryDirs", () => {
|
|
|
79
79
|
}
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
|
+
|
|
83
|
+
// A release bundle arrives with whatever modes the packaging pipeline kept —
|
|
84
|
+
// in practice none. Only the main binary used to be chmod'ed, so ayrs was left
|
|
85
|
+
// non-executable and the daemon died with EACCES on every supervisor restart.
|
|
86
|
+
describe("chmodExtractedBinaries", () => {
|
|
87
|
+
it("marks every extracted regular file executable and skips archives and dirs", async () => {
|
|
88
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "ay-bin-"));
|
|
89
|
+
try {
|
|
90
|
+
writeFileSync(path.join(dir, "agent-yes-linux-x64"), "#!/bin/sh\n", { mode: 0o644 });
|
|
91
|
+
writeFileSync(path.join(dir, "ayrs"), "#!/bin/sh\n", { mode: 0o644 });
|
|
92
|
+
writeFileSync(path.join(dir, "temp.tar.gz"), "x");
|
|
93
|
+
mkdirSync(path.join(dir, "sub"));
|
|
94
|
+
const fixed = await chmodExtractedBinaries(dir);
|
|
95
|
+
expect(fixed.sort()).toEqual(["agent-yes-linux-x64", "ayrs"]);
|
|
96
|
+
if (process.platform !== "win32") {
|
|
97
|
+
expect(statSync(path.join(dir, "ayrs")).mode & 0o111).toBe(0o111);
|
|
98
|
+
expect(statSync(path.join(dir, "temp.tar.gz")).mode & 0o111).toBe(0);
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(dir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("ensureExecutable", () => {
|
|
107
|
+
it("repairs a present-but-not-executable binary and returns its path", () => {
|
|
108
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "ay-bin-"));
|
|
109
|
+
try {
|
|
110
|
+
const p = path.join(dir, "ayrs");
|
|
111
|
+
writeFileSync(p, "#!/bin/sh\n", { mode: 0o644 });
|
|
112
|
+
expect(ensureExecutable(p)).toBe(p);
|
|
113
|
+
if (process.platform !== "win32") expect(statSync(p).mode & 0o111).toBe(0o111);
|
|
114
|
+
} finally {
|
|
115
|
+
rmSync(dir, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
package/ts/rustBinary.spec.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
3
11
|
import os from "node:os";
|
|
4
12
|
import path from "node:path";
|
|
5
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
chmodExtractedBinaries,
|
|
15
|
+
devBuildInfo,
|
|
16
|
+
ensureExecutable,
|
|
17
|
+
gcOldBinaryDirs,
|
|
18
|
+
hasValidMacSignature,
|
|
19
|
+
} from "./rustBinary.ts";
|
|
6
20
|
import { _setInstalledPackageForTesting } from "./versionChecker.ts";
|
|
7
21
|
|
|
8
22
|
// Startup GC for the versioned binary download cache (PERFORMANCE-EVENT
|
|
@@ -135,3 +149,40 @@ describe("hasValidMacSignature", () => {
|
|
|
135
149
|
expect(hasValidMacSignature(path.join(root, "does-not-exist"))).toBe(true);
|
|
136
150
|
});
|
|
137
151
|
});
|
|
152
|
+
|
|
153
|
+
// A release bundle arrives with whatever modes the packaging pipeline kept —
|
|
154
|
+
// in practice none. Only the main binary used to be chmod'ed, so ayrs was left
|
|
155
|
+
// non-executable and the daemon died with EACCES on every supervisor restart.
|
|
156
|
+
describe("chmodExtractedBinaries", () => {
|
|
157
|
+
it("marks every extracted regular file executable and skips archives and dirs", async () => {
|
|
158
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "ay-bin-"));
|
|
159
|
+
try {
|
|
160
|
+
writeFileSync(path.join(dir, "agent-yes-linux-x64"), "#!/bin/sh\n", { mode: 0o644 });
|
|
161
|
+
writeFileSync(path.join(dir, "ayrs"), "#!/bin/sh\n", { mode: 0o644 });
|
|
162
|
+
writeFileSync(path.join(dir, "temp.tar.gz"), "x");
|
|
163
|
+
mkdirSync(path.join(dir, "sub"));
|
|
164
|
+
const fixed = await chmodExtractedBinaries(dir);
|
|
165
|
+
expect(fixed.sort()).toEqual(["agent-yes-linux-x64", "ayrs"]);
|
|
166
|
+
if (process.platform !== "win32") {
|
|
167
|
+
expect(statSync(path.join(dir, "ayrs")).mode & 0o111).toBe(0o111);
|
|
168
|
+
expect(statSync(path.join(dir, "temp.tar.gz")).mode & 0o111).toBe(0);
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(dir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("ensureExecutable", () => {
|
|
177
|
+
it("repairs a present-but-not-executable binary and returns its path", () => {
|
|
178
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "ay-bin-"));
|
|
179
|
+
try {
|
|
180
|
+
const p = path.join(dir, "ayrs");
|
|
181
|
+
writeFileSync(p, "#!/bin/sh\n", { mode: 0o644 });
|
|
182
|
+
expect(ensureExecutable(p)).toBe(p);
|
|
183
|
+
if (process.platform !== "win32") expect(statSync(p).mode & 0o111).toBe(0o111);
|
|
184
|
+
} finally {
|
|
185
|
+
rmSync(dir, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
package/ts/rustBinary.ts
CHANGED
|
@@ -3,7 +3,17 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { execFileSync } from "child_process";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
accessSync,
|
|
8
|
+
chmodSync,
|
|
9
|
+
constants as fsConstants,
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
statSync,
|
|
15
|
+
unlinkSync,
|
|
16
|
+
} from "fs";
|
|
7
17
|
import { chmod, copyFile } from "fs/promises";
|
|
8
18
|
import path from "path";
|
|
9
19
|
import { compareVersions, getInstalledPackage } from "./versionChecker.ts";
|
|
@@ -251,11 +261,57 @@ export function findAyrsBinary(): string | undefined {
|
|
|
251
261
|
Bun.which("ayrs") ?? "",
|
|
252
262
|
];
|
|
253
263
|
for (const p of searchPaths) {
|
|
254
|
-
if (p && existsSync(p)) return p;
|
|
264
|
+
if (p && existsSync(p)) return ensureExecutable(p);
|
|
255
265
|
}
|
|
256
266
|
return undefined;
|
|
257
267
|
}
|
|
258
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Repair a binary that is present but not executable (POSIX only; Windows has
|
|
271
|
+
* no exec bit). A release bundle that lost its modes in transit, or a cache
|
|
272
|
+
* written by an older fetcher that only chmod'ed the main binary, leaves
|
|
273
|
+
* `ayrs` as a plain file: the daemon then dies with EACCES on every restart
|
|
274
|
+
* and the supervisor loops forever. Best effort — a read-only cache keeps
|
|
275
|
+
* failing, and the caller's spawn reports that.
|
|
276
|
+
*/
|
|
277
|
+
export function ensureExecutable(p: string): string {
|
|
278
|
+
if (process.platform === "win32") return p;
|
|
279
|
+
try {
|
|
280
|
+
accessSync(p, fsConstants.X_OK);
|
|
281
|
+
} catch {
|
|
282
|
+
try {
|
|
283
|
+
chmodSync(p, 0o755);
|
|
284
|
+
} catch {
|
|
285
|
+
/* read-only cache — leave the spawn to report it */
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return p;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Mark every regular file a release bundle extracted into `dir` executable.
|
|
293
|
+
* The tarball carries ayrs (and friends) next to the main binary; the modes
|
|
294
|
+
* they arrive with are whatever the packaging pipeline preserved, which has
|
|
295
|
+
* been "none" (CI artifacts drop Unix permissions). Chmod'ing the main binary
|
|
296
|
+
* alone (the old behaviour) left ayrs non-executable — see ensureExecutable.
|
|
297
|
+
*/
|
|
298
|
+
export async function chmodExtractedBinaries(dir: string): Promise<string[]> {
|
|
299
|
+
const fixed: string[] = [];
|
|
300
|
+
for (const name of readdirSync(dir)) {
|
|
301
|
+
const p = path.join(dir, name);
|
|
302
|
+
let isFile = false;
|
|
303
|
+
try {
|
|
304
|
+
isFile = statSync(p).isFile();
|
|
305
|
+
} catch {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (!isFile || /\.(tar\.gz|zip|json|txt|md)$/i.test(name)) continue;
|
|
309
|
+
await chmod(p, 0o755);
|
|
310
|
+
fixed.push(name);
|
|
311
|
+
}
|
|
312
|
+
return fixed;
|
|
313
|
+
}
|
|
314
|
+
|
|
259
315
|
/**
|
|
260
316
|
* Get GitHub release download URL for the binary
|
|
261
317
|
*/
|
|
@@ -351,7 +407,9 @@ export async function downloadBinary(verbose = false): Promise<string> {
|
|
|
351
407
|
unlinkSync(tarPath);
|
|
352
408
|
} catch {}
|
|
353
409
|
|
|
354
|
-
// Make executable
|
|
410
|
+
// Make everything the bundle shipped executable — ayrs and the other
|
|
411
|
+
// helpers alongside the main binary, not just binaryPath.
|
|
412
|
+
await chmodExtractedBinaries(binDir);
|
|
355
413
|
await chmod(binaryPath, 0o755);
|
|
356
414
|
}
|
|
357
415
|
|