Skip to content

Commit 4f02404

Browse files
Copilotfuxingloh
andauthored
feat: add update command with daily auto-update (USE-24) (#41)
* Initial plan * feat: add update command with daily auto-update (USE-24) Co-authored-by: fuxingloh <4266087+fuxingloh@users.noreply.github.com> * feat: allow disabling auto-update via config env.USE_AGENTLY_AUTO_UPDATE Co-authored-by: fuxingloh <4266087+fuxingloh@users.noreply.github.com> * refactor: use Zod for state/config schemas; log auto-update errors Co-authored-by: fuxingloh <4266087+fuxingloh@users.noreply.github.com> * chore: plan simplification of update.ts and state relocation Co-authored-by: fuxingloh <4266087+fuxingloh@users.noreply.github.com> * refactor: simplify update.ts — inline constants, move state, remove isNewerVersion Co-authored-by: fuxingloh <4266087+fuxingloh@users.noreply.github.com> * update * clean up * fix testing problem * fix * clean up skill --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: fuxingloh <4266087+fuxingloh@users.noreply.github.com>
1 parent 91fde99 commit 4f02404

File tree

9 files changed

+375
-39
lines changed

9 files changed

+375
-39
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/use-agently/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"@x402/fetch": "^2.4.0",
3030
"commander": "^14.0.3",
3131
"viem": "^2.46.3",
32-
"yaml": "^2.8.2"
32+
"yaml": "^2.8.2",
33+
"zod": "^4"
3334
},
3435
"devDependencies": {
3536
"@types/bun": "^1.3.9",

packages/use-agently/src/bin.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#!/usr/bin/env node
22

33
import { cli } from "./cli";
4+
import { checkAutoUpdate } from "./commands/update.js";
45

5-
cli.parse();
6+
await cli.parseAsync();
7+
await checkAutoUpdate();

packages/use-agently/src/cli.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,37 @@
11
import { Command } from "commander";
2-
import { initCommand } from "./commands/init.js";
3-
import { whoamiCommand } from "./commands/whoami.js";
4-
import { balanceCommand } from "./commands/balance.js";
5-
import { agentsCommand } from "./commands/agents.js";
6-
import { a2aCommand, a2aCardCommand } from "./commands/a2a.js";
7-
import { doctorCommand } from "./commands/doctor.js";
2+
import { initCommand } from "./commands/init";
3+
import { whoamiCommand } from "./commands/whoami";
4+
import { balanceCommand } from "./commands/balance";
5+
import { agentsCommand } from "./commands/agents";
6+
import { a2aCommand, a2aCardCommand } from "./commands/a2a";
7+
import { doctorCommand } from "./commands/doctor";
8+
import { updateCommand } from "./commands/update";
89

910
export const cli = new Command();
1011

1112
cli
1213
.name("use-agently")
13-
.description("use-agently CLI")
14+
.description(
15+
"Agently is the way AI coordinate and transact. The routing and settlement layer for your agent economy.",
16+
)
1417
.version("0.0.0")
1518
.option("-o, --output <format>", "Output format (text, json)", "text")
16-
.addHelpCommand("help", "Print available commands")
17-
.action(() => cli.outputHelp());
19+
.action(() => {
20+
cli.outputHelp();
21+
});
1822

19-
// Lifecycle & Health
20-
cli.addCommand(initCommand.helpGroup("Lifecycle & Health"));
21-
cli.addCommand(doctorCommand.helpGroup("Lifecycle & Health"));
22-
cli.addCommand(whoamiCommand.helpGroup("Lifecycle & Health"));
23-
cli.addCommand(balanceCommand.helpGroup("Lifecycle & Health"));
23+
// Diagnostics
24+
cli.addCommand(doctorCommand.helpGroup("Diagnostics"));
25+
cli.addCommand(whoamiCommand.helpGroup("Diagnostics"));
26+
cli.addCommand(balanceCommand.helpGroup("Diagnostics"));
2427

2528
// Discovery
2629
cli.addCommand(agentsCommand.helpGroup("Discovery"));
2730

2831
// Protocols
2932
cli.addCommand(a2aCommand.helpGroup("Protocols"));
3033
cli.addCommand(a2aCardCommand.helpGroup("Protocols"));
34+
35+
// Lifecycle
36+
cli.addCommand(initCommand.helpGroup("Lifecycle"));
37+
cli.addCommand(updateCommand.helpGroup("Lifecycle"));

packages/use-agently/src/commands/help.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,16 @@ describe("help command", () => {
1818
exitSpy.mockRestore();
1919
});
2020

21-
test("use-agently help prints available commands", async () => {
21+
test("use-agently --help prints available commands", async () => {
2222
try {
23-
await cli.parseAsync(["test", "use-agently", "help"]);
23+
await cli.parseAsync(["test", "use-agently", "--help"]);
2424
} catch {
25-
// expected: help calls process.exit(0)
25+
// expected: --help calls process.exit(0)
2626
}
2727

2828
const output = writeSpy.mock.calls.map((c) => c[0]).join("");
2929
expect(output).toContain("use-agently");
30-
expect(output).toContain("Commands:");
30+
expect(output).toContain("Diagnostics");
3131
expect(exitSpy).toHaveBeenCalledWith(0);
3232
});
3333

@@ -36,7 +36,7 @@ describe("help command", () => {
3636

3737
const output = writeSpy.mock.calls.map((c) => c[0]).join("");
3838
expect(output).toContain("use-agently");
39-
expect(output).toContain("Commands:");
39+
expect(output).toContain("Diagnostics");
4040
expect(exitSpy).not.toHaveBeenCalled();
4141
});
4242
});
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
2+
import { captureOutput } from "../testing";
3+
4+
const mockReadFile = mock(async (_path: string, _encoding: unknown) => JSON.stringify({}));
5+
const mockWriteFile = mock(async (_path: string, _data: string, _encoding?: unknown) => {});
6+
const mockMkdir = mock(async () => {});
7+
const mockLoadConfig = mock(async () => undefined as unknown);
8+
9+
mock.module("node:fs/promises", () => ({
10+
readFile: mockReadFile,
11+
writeFile: mockWriteFile,
12+
mkdir: mockMkdir,
13+
rename: mock(async () => {}),
14+
}));
15+
16+
mock.module("../config", () => ({
17+
loadConfig: mockLoadConfig,
18+
saveConfig: async () => {},
19+
backupConfig: async () => "",
20+
getConfigOrThrow: async () => {
21+
throw new Error("No wallet configured.");
22+
},
23+
}));
24+
25+
mock.module("node:child_process", () => ({
26+
execSync: mock(() => {}),
27+
}));
28+
29+
const { cli } = await import("../cli");
30+
const { CURRENT_VERSION, checkAutoUpdate } = await import("./update");
31+
const updateModule = await import("./update");
32+
33+
describe("update command", () => {
34+
const out = captureOutput();
35+
let fetchSpy: ReturnType<typeof spyOn>;
36+
let exitSpy: ReturnType<typeof spyOn>;
37+
38+
beforeEach(() => {
39+
mockReadFile.mockClear();
40+
mockWriteFile.mockClear();
41+
mockMkdir.mockClear();
42+
mockReadFile.mockImplementation(async () => JSON.stringify({}));
43+
fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue({
44+
ok: true,
45+
json: async () => ({ version: "9.9.9" }),
46+
} as Response);
47+
exitSpy = spyOn(process, "exit").mockImplementation(() => {
48+
throw new Error("process.exit");
49+
});
50+
});
51+
52+
afterEach(() => {
53+
fetchSpy.mockRestore();
54+
exitSpy.mockRestore();
55+
});
56+
57+
test("text output - update available", async () => {
58+
await cli.parseAsync(["test", "use-agently", "update"]);
59+
60+
expect(out.yaml).toEqual({
61+
current: CURRENT_VERSION,
62+
latest: "9.9.9",
63+
updated: true,
64+
});
65+
});
66+
67+
test("json output - update available", async () => {
68+
await cli.parseAsync(["test", "use-agently", "-o", "json", "update"]);
69+
70+
expect(out.json).toEqual({
71+
current: CURRENT_VERSION,
72+
latest: "9.9.9",
73+
updated: true,
74+
});
75+
});
76+
77+
test("no update when already on latest", async () => {
78+
fetchSpy.mockResolvedValue({
79+
ok: true,
80+
json: async () => ({ version: CURRENT_VERSION }),
81+
} as Response);
82+
83+
await cli.parseAsync(["test", "use-agently", "-o", "json", "update"]);
84+
85+
expect(out.json).toEqual({
86+
current: CURRENT_VERSION,
87+
latest: CURRENT_VERSION,
88+
updated: false,
89+
});
90+
});
91+
92+
test("saves lastUpdateCheck after update", async () => {
93+
await cli.parseAsync(["test", "use-agently", "update"]);
94+
95+
expect(mockWriteFile).toHaveBeenCalledTimes(1);
96+
const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string);
97+
expect(typeof written.lastUpdateCheck).toBe("string");
98+
});
99+
100+
test("exits with 1 on registry error", async () => {
101+
fetchSpy.mockResolvedValue({ ok: false, status: 503 } as Response);
102+
103+
try {
104+
await cli.parseAsync(["test", "use-agently", "update"]);
105+
} catch {
106+
// expected: process.exit throws
107+
}
108+
109+
expect(exitSpy).toHaveBeenCalledWith(1);
110+
});
111+
});
112+
113+
describe("checkAutoUpdate", () => {
114+
let fetchSpy: ReturnType<typeof spyOn>;
115+
let devVersionSpy: ReturnType<typeof spyOn>;
116+
117+
beforeEach(() => {
118+
mockReadFile.mockClear();
119+
mockWriteFile.mockClear();
120+
mockMkdir.mockClear();
121+
mockLoadConfig.mockImplementation(async () => undefined);
122+
mockReadFile.mockImplementation(async () => {
123+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
124+
});
125+
fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue({
126+
ok: true,
127+
json: async () => ({ version: "9.9.9" }),
128+
} as Response);
129+
devVersionSpy = spyOn(updateModule, "isDevVersion").mockReturnValue(false);
130+
});
131+
132+
afterEach(() => {
133+
fetchSpy.mockRestore();
134+
devVersionSpy.mockRestore();
135+
});
136+
137+
test("skips update check when checked within 24h", async () => {
138+
mockReadFile.mockResolvedValue(JSON.stringify({ lastUpdateCheck: new Date().toISOString() }));
139+
140+
await checkAutoUpdate();
141+
142+
expect(mockWriteFile).not.toHaveBeenCalled();
143+
});
144+
145+
test("runs update check when last check was >24h ago", async () => {
146+
const yesterday = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
147+
mockReadFile.mockResolvedValue(JSON.stringify({ lastUpdateCheck: yesterday }));
148+
149+
await checkAutoUpdate();
150+
151+
expect(mockWriteFile).toHaveBeenCalledTimes(1);
152+
});
153+
154+
test("runs update check when no prior check recorded", async () => {
155+
await checkAutoUpdate();
156+
157+
expect(mockWriteFile).toHaveBeenCalledTimes(1);
158+
});
159+
160+
test("skips when USE_AGENTLY_AUTO_UPDATE is 0 in config", async () => {
161+
mockLoadConfig.mockImplementation(async () => ({ env: { USE_AGENTLY_AUTO_UPDATE: 0 } }));
162+
163+
await checkAutoUpdate();
164+
165+
expect(mockWriteFile).not.toHaveBeenCalled();
166+
expect(fetchSpy).not.toHaveBeenCalled();
167+
});
168+
169+
test("runs when USE_AGENTLY_AUTO_UPDATE is 1 in config", async () => {
170+
mockLoadConfig.mockImplementation(async () => ({ env: { USE_AGENTLY_AUTO_UPDATE: 1 } }));
171+
172+
await checkAutoUpdate();
173+
174+
expect(mockWriteFile).toHaveBeenCalledTimes(1);
175+
});
176+
177+
test("logs warning but does not throw on errors", async () => {
178+
fetchSpy.mockResolvedValue({ ok: false, status: 500 } as Response);
179+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
180+
181+
await expect(checkAutoUpdate()).resolves.toBeUndefined();
182+
expect(warnSpy).toHaveBeenCalledWith("Auto-update failed:", expect.any(String));
183+
warnSpy.mockRestore();
184+
});
185+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Command } from "commander";
2+
import { execSync } from "node:child_process";
3+
import { homedir } from "node:os";
4+
import { join } from "node:path";
5+
import { mkdir, readFile, writeFile } from "node:fs/promises";
6+
import { z } from "zod";
7+
import { output } from "../output.js";
8+
import { loadConfig } from "../config.js";
9+
import pkg from "../../package.json" with { type: "json" };
10+
11+
const UpdateStateSchema = z.object({
12+
lastUpdateCheck: z.string().optional(),
13+
});
14+
15+
type UpdateState = z.infer<typeof UpdateStateSchema>;
16+
17+
function getUpdateStatePath(): string {
18+
return join(homedir(), ".use-agently", "update-state.json");
19+
}
20+
21+
async function loadUpdateState(): Promise<UpdateState> {
22+
try {
23+
const contents = await readFile(getUpdateStatePath(), "utf8");
24+
const result = UpdateStateSchema.safeParse(JSON.parse(contents));
25+
return result.success ? result.data : {};
26+
} catch {
27+
return {};
28+
}
29+
}
30+
31+
async function saveUpdateState(state: UpdateState): Promise<void> {
32+
await mkdir(join(homedir(), ".use-agently"), { recursive: true });
33+
await writeFile(getUpdateStatePath(), JSON.stringify(state, null, 2) + "\n", "utf8");
34+
}
35+
36+
export const CURRENT_VERSION: string = pkg.version;
37+
38+
export function isDevVersion(): boolean {
39+
return CURRENT_VERSION === "0.0.0";
40+
}
41+
42+
export async function getLatestVersion(): Promise<string> {
43+
const res = await fetch("https://registry.npmjs.org/use-agently/latest");
44+
if (!res.ok) throw new Error(`Failed to check npm registry: ${res.status}`);
45+
const data = (await res.json()) as { version: string };
46+
return data.version;
47+
}
48+
49+
export async function runUpdate(version: string): Promise<void> {
50+
if (!/^\d+\.\d+\.\d+$/.test(version)) {
51+
throw new Error(`Invalid version format received from registry: ${version}`);
52+
}
53+
execSync(`npm install -g use-agently@${version}`, { stdio: "pipe" });
54+
}
55+
56+
export async function checkAndUpdate(): Promise<{ current: string; latest: string; updated: boolean }> {
57+
const current = CURRENT_VERSION;
58+
const latest = await getLatestVersion();
59+
const needsUpdate = current !== latest;
60+
61+
if (needsUpdate) {
62+
await runUpdate(latest);
63+
}
64+
65+
const state = await loadUpdateState();
66+
await saveUpdateState({ ...state, lastUpdateCheck: new Date().toISOString() });
67+
68+
return { current, latest, updated: needsUpdate };
69+
}
70+
71+
export async function checkAutoUpdate(): Promise<void> {
72+
try {
73+
if (isDevVersion()) return;
74+
75+
const config = await loadConfig();
76+
if ((config?.env?.USE_AGENTLY_AUTO_UPDATE ?? 1) === 0) return;
77+
78+
const state = await loadUpdateState();
79+
const lastCheck = state.lastUpdateCheck ? new Date(state.lastUpdateCheck).getTime() : 0;
80+
const hoursSinceLastCheck = (Date.now() - lastCheck) / (1000 * 60 * 60);
81+
82+
if (hoursSinceLastCheck < 24) return;
83+
84+
await checkAndUpdate();
85+
} catch (err) {
86+
console.warn("Auto-update failed:", err instanceof Error ? err.message : String(err));
87+
}
88+
}
89+
90+
export const updateCommand = new Command("update")
91+
.description("Update use-agently to the latest version")
92+
.action(async (_options: Record<string, never>, command: Command) => {
93+
try {
94+
const result = await checkAndUpdate();
95+
output(command, result);
96+
} catch (err) {
97+
console.error(err instanceof Error ? err.message : String(err));
98+
process.exit(1);
99+
}
100+
});

0 commit comments

Comments
 (0)