@sweny-ai/core 0.1.66 → 0.1.67
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/cli/__tests__/upgrade.test.d.ts +1 -0
- package/dist/cli/__tests__/upgrade.test.js +306 -0
- package/dist/cli/__tests__/version-check.test.d.ts +1 -0
- package/dist/cli/__tests__/version-check.test.js +192 -0
- package/dist/cli/main.js +65 -1
- package/dist/cli/upgrade.d.ts +74 -0
- package/dist/cli/upgrade.js +219 -0
- package/dist/cli/version-check.d.ts +62 -0
- package/dist/cli/version-check.js +123 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { compareVersions, detectPackageManager, resolveInstallRoot, installCommandFor, runUpgrade, fetchLatestFromNpm, } from "../upgrade.js";
|
|
3
|
+
// In-memory writable stream for pure assertions on output. Cheaper and
|
|
4
|
+
// simpler than a real Node stream and identical to the helper used by
|
|
5
|
+
// diagram.test.ts — keeps test utilities consistent across the cli/ dir.
|
|
6
|
+
function captureStream() {
|
|
7
|
+
const chunks = [];
|
|
8
|
+
const stream = {
|
|
9
|
+
write: (chunk) => {
|
|
10
|
+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
|
11
|
+
return true;
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
stream,
|
|
16
|
+
get text() {
|
|
17
|
+
return chunks.join("");
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build an UpgradeDeps object with sensible defaults. Tests override only
|
|
23
|
+
* the bits relevant to the behavior they're asserting — keeps each case
|
|
24
|
+
* focused on its condition rather than plumbing.
|
|
25
|
+
*/
|
|
26
|
+
function makeDeps(over = {}) {
|
|
27
|
+
const stdout = captureStream();
|
|
28
|
+
const stderr = captureStream();
|
|
29
|
+
const exit = vi.fn();
|
|
30
|
+
const runInstall = vi.fn(() => 0);
|
|
31
|
+
const deps = {
|
|
32
|
+
currentVersion: "0.1.65",
|
|
33
|
+
installPath: "/Users/x/.nvm/versions/node/v22/lib/node_modules/@sweny-ai/core/dist/cli/main.js",
|
|
34
|
+
fetchLatestVersion: vi.fn(async () => "0.1.66"),
|
|
35
|
+
runInstall,
|
|
36
|
+
canWrite: () => true,
|
|
37
|
+
stdout: stdout.stream,
|
|
38
|
+
stderr: stderr.stream,
|
|
39
|
+
exit,
|
|
40
|
+
...over,
|
|
41
|
+
};
|
|
42
|
+
return { deps, stdout, stderr, exit, runInstall };
|
|
43
|
+
}
|
|
44
|
+
describe("compareVersions", () => {
|
|
45
|
+
it("ranks by numeric core segments", () => {
|
|
46
|
+
expect(compareVersions("0.1.10", "0.1.9")).toBeGreaterThan(0);
|
|
47
|
+
expect(compareVersions("0.1.9", "0.1.10")).toBeLessThan(0);
|
|
48
|
+
expect(compareVersions("1.0.0", "0.9.999")).toBeGreaterThan(0);
|
|
49
|
+
expect(compareVersions("0.1.65", "0.1.65")).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
it("handles missing segments as zero", () => {
|
|
52
|
+
expect(compareVersions("1", "1.0.0")).toBe(0);
|
|
53
|
+
expect(compareVersions("1.0", "1.0.1")).toBeLessThan(0);
|
|
54
|
+
});
|
|
55
|
+
it("ranks pre-releases below their release", () => {
|
|
56
|
+
expect(compareVersions("1.0.0-alpha.1", "1.0.0")).toBeLessThan(0);
|
|
57
|
+
expect(compareVersions("1.0.0", "1.0.0-beta.3")).toBeGreaterThan(0);
|
|
58
|
+
expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBeLessThan(0);
|
|
59
|
+
});
|
|
60
|
+
it("treats non-numeric core segments as zero (defensive)", () => {
|
|
61
|
+
// Malformed numeric segments shouldn't throw or return NaN — they
|
|
62
|
+
// collapse to 0 so the comparison still returns a deterministic ordering.
|
|
63
|
+
expect(compareVersions("1.x.0", "1.0.0")).toBe(0);
|
|
64
|
+
expect(compareVersions("1.0.y", "1.0.0")).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe("detectPackageManager", () => {
|
|
68
|
+
it("detects npm from nvm-style layout", () => {
|
|
69
|
+
expect(detectPackageManager("/Users/x/.nvm/versions/node/v22.22.0/lib/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("npm");
|
|
70
|
+
});
|
|
71
|
+
it("detects npm from fnm / asdf / vanilla Node paths", () => {
|
|
72
|
+
expect(detectPackageManager("/usr/lib/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("npm");
|
|
73
|
+
expect(detectPackageManager("/home/x/.fnm/node-versions/v22/installation/lib/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("npm");
|
|
74
|
+
});
|
|
75
|
+
it("detects pnpm global installs", () => {
|
|
76
|
+
expect(detectPackageManager("/Users/x/.local/share/pnpm/global/5/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("pnpm");
|
|
77
|
+
expect(detectPackageManager("/Users/x/Library/pnpm-global/5/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("pnpm");
|
|
78
|
+
});
|
|
79
|
+
it("detects homebrew", () => {
|
|
80
|
+
expect(detectPackageManager("/opt/homebrew/Cellar/sweny/0.1.65/libexec/bin/sweny")).toBe("brew");
|
|
81
|
+
expect(detectPackageManager("/usr/local/Cellar/sweny/0.1.65/libexec/bin/sweny")).toBe("brew");
|
|
82
|
+
});
|
|
83
|
+
it("detects volta-managed installs", () => {
|
|
84
|
+
expect(detectPackageManager("/Users/x/.volta/tools/image/packages/@sweny-ai/core/bin/sweny")).toBe("volta");
|
|
85
|
+
});
|
|
86
|
+
it("detects bun global installs", () => {
|
|
87
|
+
expect(detectPackageManager("/Users/x/.bun/install/global/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("bun");
|
|
88
|
+
});
|
|
89
|
+
it("detects yarn global", () => {
|
|
90
|
+
expect(detectPackageManager("/Users/x/.yarn/global/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("yarn");
|
|
91
|
+
});
|
|
92
|
+
it("normalizes Windows paths (forward-slash rules)", () => {
|
|
93
|
+
const win = "C:\\Users\\x\\AppData\\Roaming\\npm\\node_modules\\@sweny-ai\\core\\dist\\cli\\main.js";
|
|
94
|
+
expect(detectPackageManager(win)).toBe("npm");
|
|
95
|
+
});
|
|
96
|
+
it("returns 'unknown' for paths that don't look like a package-manager install", () => {
|
|
97
|
+
expect(detectPackageManager("/tmp/some-random-path/main.js")).toBe("unknown");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe("resolveInstallRoot", () => {
|
|
101
|
+
it("returns the prefix above node_modules for a typical npm layout", () => {
|
|
102
|
+
const p = "/Users/x/.nvm/versions/node/v22/lib/node_modules/@sweny-ai/core/dist/cli/main.js";
|
|
103
|
+
expect(resolveInstallRoot(p)).toBe("/Users/x/.nvm/versions/node/v22/lib");
|
|
104
|
+
});
|
|
105
|
+
it("returns null when @sweny-ai is not in the parent chain", () => {
|
|
106
|
+
expect(resolveInstallRoot("/tmp/random/main.js")).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe("installCommandFor", () => {
|
|
110
|
+
it("emits the correct CLI invocation per package manager", () => {
|
|
111
|
+
expect(installCommandFor("npm", "latest")).toEqual({
|
|
112
|
+
cmd: "npm",
|
|
113
|
+
args: ["install", "-g", "@sweny-ai/core@latest"],
|
|
114
|
+
});
|
|
115
|
+
expect(installCommandFor("pnpm", "latest")).toEqual({ cmd: "pnpm", args: ["add", "-g", "@sweny-ai/core@latest"] });
|
|
116
|
+
expect(installCommandFor("yarn", "latest")).toEqual({
|
|
117
|
+
cmd: "yarn",
|
|
118
|
+
args: ["global", "add", "@sweny-ai/core@latest"],
|
|
119
|
+
});
|
|
120
|
+
expect(installCommandFor("bun", "latest")).toEqual({ cmd: "bun", args: ["add", "-g", "@sweny-ai/core@latest"] });
|
|
121
|
+
expect(installCommandFor("volta", "latest")).toEqual({ cmd: "volta", args: ["install", "@sweny-ai/core@latest"] });
|
|
122
|
+
});
|
|
123
|
+
it("passes through non-default tags", () => {
|
|
124
|
+
expect(installCommandFor("npm", "beta")).toEqual({ cmd: "npm", args: ["install", "-g", "@sweny-ai/core@beta"] });
|
|
125
|
+
});
|
|
126
|
+
it("returns null for brew / unknown (can't drive automatically)", () => {
|
|
127
|
+
expect(installCommandFor("brew", "latest")).toBeNull();
|
|
128
|
+
expect(installCommandFor("unknown", "latest")).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
describe("runUpgrade — no-op paths", () => {
|
|
132
|
+
it("reports already-latest and exits 0", async () => {
|
|
133
|
+
const { deps, stdout, exit, runInstall } = makeDeps({
|
|
134
|
+
currentVersion: "0.1.66",
|
|
135
|
+
fetchLatestVersion: vi.fn(async () => "0.1.66"),
|
|
136
|
+
});
|
|
137
|
+
await runUpgrade({}, deps);
|
|
138
|
+
expect(stdout.text).toMatch(/already the latest/);
|
|
139
|
+
expect(runInstall).not.toHaveBeenCalled();
|
|
140
|
+
expect(exit).toHaveBeenCalledWith(0);
|
|
141
|
+
});
|
|
142
|
+
it("reports when running ahead of the registry (local build) and exits 0", async () => {
|
|
143
|
+
const { deps, stdout, exit, runInstall } = makeDeps({
|
|
144
|
+
currentVersion: "0.2.0",
|
|
145
|
+
fetchLatestVersion: vi.fn(async () => "0.1.66"),
|
|
146
|
+
});
|
|
147
|
+
await runUpgrade({}, deps);
|
|
148
|
+
expect(stdout.text).toMatch(/newer than the published/);
|
|
149
|
+
expect(runInstall).not.toHaveBeenCalled();
|
|
150
|
+
expect(exit).toHaveBeenCalledWith(0);
|
|
151
|
+
});
|
|
152
|
+
it("installs anyway when --force is set even if already latest", async () => {
|
|
153
|
+
const { deps, runInstall, exit } = makeDeps({
|
|
154
|
+
currentVersion: "0.1.66",
|
|
155
|
+
fetchLatestVersion: vi.fn(async () => "0.1.66"),
|
|
156
|
+
});
|
|
157
|
+
await runUpgrade({ force: true }, deps);
|
|
158
|
+
expect(runInstall).toHaveBeenCalledWith("npm", ["install", "-g", "@sweny-ai/core@latest"]);
|
|
159
|
+
expect(exit).toHaveBeenCalledWith(0);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
describe("runUpgrade — install dispatch", () => {
|
|
163
|
+
it("invokes the detected package manager and exits 0 on success", async () => {
|
|
164
|
+
const { deps, stdout, runInstall, exit } = makeDeps();
|
|
165
|
+
await runUpgrade({}, deps);
|
|
166
|
+
expect(runInstall).toHaveBeenCalledWith("npm", ["install", "-g", "@sweny-ai/core@latest"]);
|
|
167
|
+
expect(stdout.text).toMatch(/Upgrading sweny 0\.1\.65 → 0\.1\.66/);
|
|
168
|
+
expect(stdout.text).toMatch(/✓ Upgraded sweny to 0\.1\.66/);
|
|
169
|
+
expect(exit).toHaveBeenCalledWith(0);
|
|
170
|
+
});
|
|
171
|
+
it("uses pnpm when the install path is a pnpm global root", async () => {
|
|
172
|
+
const { deps, runInstall } = makeDeps({
|
|
173
|
+
installPath: "/Users/x/.local/share/pnpm/global/5/node_modules/@sweny-ai/core/dist/cli/main.js",
|
|
174
|
+
});
|
|
175
|
+
await runUpgrade({}, deps);
|
|
176
|
+
expect(runInstall).toHaveBeenCalledWith("pnpm", ["add", "-g", "@sweny-ai/core@latest"]);
|
|
177
|
+
});
|
|
178
|
+
it("forwards a non-default --tag to the install command", async () => {
|
|
179
|
+
const { deps, runInstall } = makeDeps({
|
|
180
|
+
fetchLatestVersion: vi.fn(async (tag) => (tag === "beta" ? "0.2.0-beta.1" : "0.1.66")),
|
|
181
|
+
});
|
|
182
|
+
await runUpgrade({ tag: "beta" }, deps);
|
|
183
|
+
expect(runInstall).toHaveBeenCalledWith("npm", ["install", "-g", "@sweny-ai/core@beta"]);
|
|
184
|
+
});
|
|
185
|
+
it("propagates a non-zero install exit code", async () => {
|
|
186
|
+
const { deps, stderr, exit } = makeDeps({
|
|
187
|
+
runInstall: vi.fn(() => 42),
|
|
188
|
+
});
|
|
189
|
+
await runUpgrade({}, deps);
|
|
190
|
+
expect(stderr.text).toMatch(/exited with code 42/);
|
|
191
|
+
expect(exit).toHaveBeenCalledWith(42);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe("runUpgrade — permissions + platform edges", () => {
|
|
195
|
+
it("prints sudo hint and exits 1 when the install prefix isn't writable", async () => {
|
|
196
|
+
const { deps, stderr, exit, runInstall } = makeDeps({
|
|
197
|
+
canWrite: () => false,
|
|
198
|
+
});
|
|
199
|
+
await runUpgrade({}, deps);
|
|
200
|
+
expect(stderr.text).toMatch(/no write permission/);
|
|
201
|
+
expect(stderr.text).toMatch(/sudo npm install -g @sweny-ai\/core@latest/);
|
|
202
|
+
expect(runInstall).not.toHaveBeenCalled();
|
|
203
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
204
|
+
});
|
|
205
|
+
it("defers to brew for homebrew installs (doesn't try to npm-install over it)", async () => {
|
|
206
|
+
const { deps, stdout, stderr, exit, runInstall } = makeDeps({
|
|
207
|
+
installPath: "/opt/homebrew/Cellar/sweny/0.1.65/libexec/bin/sweny",
|
|
208
|
+
});
|
|
209
|
+
await runUpgrade({}, deps);
|
|
210
|
+
expect(stdout.text).toMatch(/0\.1\.66 is available/);
|
|
211
|
+
expect(stderr.text).toMatch(/brew upgrade sweny/);
|
|
212
|
+
expect(runInstall).not.toHaveBeenCalled();
|
|
213
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
214
|
+
});
|
|
215
|
+
it("prints a manual-install hint for unknown layouts", async () => {
|
|
216
|
+
const { deps, stdout, stderr, exit, runInstall } = makeDeps({
|
|
217
|
+
installPath: "/tmp/some-random-path/main.js",
|
|
218
|
+
});
|
|
219
|
+
await runUpgrade({}, deps);
|
|
220
|
+
expect(stdout.text).toMatch(/0\.1\.66 is available/);
|
|
221
|
+
expect(stderr.text).toMatch(/npm install -g @sweny-ai\/core@latest/);
|
|
222
|
+
expect(runInstall).not.toHaveBeenCalled();
|
|
223
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
describe("runUpgrade — --check (dry run)", () => {
|
|
227
|
+
it("reports the command without running it", async () => {
|
|
228
|
+
const { deps, stdout, runInstall, exit } = makeDeps();
|
|
229
|
+
await runUpgrade({ check: true }, deps);
|
|
230
|
+
expect(stdout.text).toMatch(/0\.1\.66 is available/);
|
|
231
|
+
expect(stdout.text).toMatch(/npm install -g @sweny-ai\/core@latest/);
|
|
232
|
+
expect(runInstall).not.toHaveBeenCalled();
|
|
233
|
+
expect(exit).toHaveBeenCalledWith(0);
|
|
234
|
+
});
|
|
235
|
+
it("still short-circuits on already-latest under --check", async () => {
|
|
236
|
+
const { deps, stdout, exit } = makeDeps({
|
|
237
|
+
currentVersion: "0.1.66",
|
|
238
|
+
fetchLatestVersion: vi.fn(async () => "0.1.66"),
|
|
239
|
+
});
|
|
240
|
+
await runUpgrade({ check: true }, deps);
|
|
241
|
+
expect(stdout.text).toMatch(/already the latest/);
|
|
242
|
+
expect(exit).toHaveBeenCalledWith(0);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
describe("runUpgrade — error handling", () => {
|
|
246
|
+
it("exits 1 with a helpful message when the registry fetch fails", async () => {
|
|
247
|
+
const { deps, stderr, exit, runInstall } = makeDeps({
|
|
248
|
+
fetchLatestVersion: vi.fn(async () => {
|
|
249
|
+
throw new Error("ENETDOWN");
|
|
250
|
+
}),
|
|
251
|
+
});
|
|
252
|
+
await runUpgrade({}, deps);
|
|
253
|
+
expect(stderr.text).toMatch(/couldn't reach the npm registry/);
|
|
254
|
+
expect(stderr.text).toMatch(/ENETDOWN/);
|
|
255
|
+
expect(runInstall).not.toHaveBeenCalled();
|
|
256
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
describe("fetchLatestFromNpm", () => {
|
|
260
|
+
it("parses the dist-tag JSON and returns the version", async () => {
|
|
261
|
+
const fetchSpy = vi
|
|
262
|
+
.spyOn(globalThis, "fetch")
|
|
263
|
+
.mockImplementation(async () => new Response(JSON.stringify({ version: "9.9.9" }), { status: 200 }));
|
|
264
|
+
try {
|
|
265
|
+
expect(await fetchLatestFromNpm("latest")).toBe("9.9.9");
|
|
266
|
+
expect(fetchSpy).toHaveBeenCalledWith("https://registry.npmjs.org/@sweny-ai/core/latest", expect.objectContaining({ headers: expect.objectContaining({ accept: "application/json" }) }));
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
fetchSpy.mockRestore();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
it("throws a clear error when the registry returns non-2xx", async () => {
|
|
273
|
+
const fetchSpy = vi
|
|
274
|
+
.spyOn(globalThis, "fetch")
|
|
275
|
+
.mockImplementation(async () => new Response("not found", { status: 404, statusText: "Not Found" }));
|
|
276
|
+
try {
|
|
277
|
+
await expect(fetchLatestFromNpm("bogus-tag")).rejects.toThrow(/404/);
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
fetchSpy.mockRestore();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
it("throws when the registry payload is missing `version`", async () => {
|
|
284
|
+
const fetchSpy = vi
|
|
285
|
+
.spyOn(globalThis, "fetch")
|
|
286
|
+
.mockImplementation(async () => new Response(JSON.stringify({}), { status: 200 }));
|
|
287
|
+
try {
|
|
288
|
+
await expect(fetchLatestFromNpm("latest")).rejects.toThrow(/version/);
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
fetchSpy.mockRestore();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
it("URL-encodes the tag so custom tags don't break the path", async () => {
|
|
295
|
+
const fetchSpy = vi
|
|
296
|
+
.spyOn(globalThis, "fetch")
|
|
297
|
+
.mockImplementation(async () => new Response(JSON.stringify({ version: "1.0.0" }), { status: 200 }));
|
|
298
|
+
try {
|
|
299
|
+
await fetchLatestFromNpm("next beta");
|
|
300
|
+
expect(fetchSpy).toHaveBeenCalledWith("https://registry.npmjs.org/@sweny-ai/core/next%20beta", expect.anything());
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
fetchSpy.mockRestore();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { shouldSkip, readCache, writeCache, isCacheFresh, formatNudge, defaultCachePath, maybeNudge, CACHE_TTL_MS, } from "../version-check.js";
|
|
6
|
+
function captureStream() {
|
|
7
|
+
const chunks = [];
|
|
8
|
+
const stream = {
|
|
9
|
+
write: (chunk) => {
|
|
10
|
+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
|
11
|
+
return true;
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
stream,
|
|
16
|
+
get text() {
|
|
17
|
+
return chunks.join("");
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
describe("shouldSkip", () => {
|
|
22
|
+
it("skips in CI", () => {
|
|
23
|
+
expect(shouldSkip({ CI: "1" }, true, "run")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
it("skips when SWENY_NO_UPDATE_CHECK is set", () => {
|
|
26
|
+
expect(shouldSkip({ SWENY_NO_UPDATE_CHECK: "1" }, true, "run")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it("skips when SWENY_OFFLINE is set", () => {
|
|
29
|
+
expect(shouldSkip({ SWENY_OFFLINE: "1" }, true, "run")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it("skips non-TTY invocations (pipes, scripts)", () => {
|
|
32
|
+
expect(shouldSkip({}, false, "run")).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it("skips the upgrade command itself to avoid double-printing", () => {
|
|
35
|
+
expect(shouldSkip({}, true, "upgrade")).toBe(true);
|
|
36
|
+
expect(shouldSkip({}, true, "update")).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it("allows the nudge in an interactive TTY with no opt-out", () => {
|
|
39
|
+
expect(shouldSkip({}, true, "run")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe("cache I/O", () => {
|
|
43
|
+
let tmpdir;
|
|
44
|
+
let cachePath;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-version-check-"));
|
|
47
|
+
cachePath = path.join(tmpdir, "sub", "version-check.json");
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
fs.rmSync(tmpdir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
it("round-trips a valid cache entry", () => {
|
|
53
|
+
writeCache(cachePath, { latest: "0.1.66", checkedAt: 12345 });
|
|
54
|
+
expect(readCache(cachePath)).toEqual({ latest: "0.1.66", checkedAt: 12345 });
|
|
55
|
+
});
|
|
56
|
+
it("creates parent directories as needed", () => {
|
|
57
|
+
writeCache(cachePath, { latest: "0.1.66", checkedAt: 12345 });
|
|
58
|
+
expect(fs.existsSync(cachePath)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
it("returns null for a missing cache file", () => {
|
|
61
|
+
expect(readCache(cachePath)).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
it("returns null when the cache is malformed JSON", () => {
|
|
64
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
65
|
+
fs.writeFileSync(cachePath, "{{{not json");
|
|
66
|
+
expect(readCache(cachePath)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
it("returns null when required fields are missing or wrong type", () => {
|
|
69
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
70
|
+
fs.writeFileSync(cachePath, JSON.stringify({ latest: "0.1.66" }));
|
|
71
|
+
expect(readCache(cachePath)).toBeNull();
|
|
72
|
+
fs.writeFileSync(cachePath, JSON.stringify({ checkedAt: 1 }));
|
|
73
|
+
expect(readCache(cachePath)).toBeNull();
|
|
74
|
+
fs.writeFileSync(cachePath, JSON.stringify({ latest: "", checkedAt: 1 }));
|
|
75
|
+
expect(readCache(cachePath)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
it("never throws on unwritable cache directories (degrades silently)", () => {
|
|
78
|
+
// Point at a path where the parent is a file — mkdirSync will throw,
|
|
79
|
+
// and writeCache must swallow it.
|
|
80
|
+
const blocker = path.join(tmpdir, "blocker");
|
|
81
|
+
fs.writeFileSync(blocker, "im-a-file");
|
|
82
|
+
expect(() => writeCache(path.join(blocker, "cache.json"), { latest: "0.1.66", checkedAt: 1 })).not.toThrow();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe("isCacheFresh", () => {
|
|
86
|
+
it("returns true within TTL", () => {
|
|
87
|
+
expect(isCacheFresh({ latest: "x", checkedAt: 1000 }, 1000 + CACHE_TTL_MS - 1)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
it("returns false at or past TTL", () => {
|
|
90
|
+
expect(isCacheFresh({ latest: "x", checkedAt: 1000 }, 1000 + CACHE_TTL_MS)).toBe(false);
|
|
91
|
+
expect(isCacheFresh({ latest: "x", checkedAt: 1000 }, 1000 + CACHE_TTL_MS + 1)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("formatNudge", () => {
|
|
95
|
+
it("includes both versions and the upgrade command", () => {
|
|
96
|
+
const out = formatNudge("0.1.65", "0.1.66");
|
|
97
|
+
expect(out).toContain("0.1.65");
|
|
98
|
+
expect(out).toContain("0.1.66");
|
|
99
|
+
expect(out).toContain("sweny upgrade");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe("defaultCachePath", () => {
|
|
103
|
+
it("honors XDG_CACHE_HOME when set", () => {
|
|
104
|
+
const p = defaultCachePath({ XDG_CACHE_HOME: "/custom/cache" }, "/home/x");
|
|
105
|
+
expect(p).toBe(path.join("/custom/cache", "sweny", "version-check.json"));
|
|
106
|
+
});
|
|
107
|
+
it("falls back to ~/.cache when XDG_CACHE_HOME is unset", () => {
|
|
108
|
+
expect(defaultCachePath({}, "/home/x")).toBe(path.join("/home/x/.cache", "sweny", "version-check.json"));
|
|
109
|
+
});
|
|
110
|
+
it("treats empty XDG_CACHE_HOME as unset (defensive)", () => {
|
|
111
|
+
expect(defaultCachePath({ XDG_CACHE_HOME: "" }, "/home/x")).toBe(path.join("/home/x/.cache", "sweny", "version-check.json"));
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe("maybeNudge", () => {
|
|
115
|
+
let tmpdir;
|
|
116
|
+
let cachePath;
|
|
117
|
+
let stderr;
|
|
118
|
+
const fetchOk = vi.fn(async () => "9.9.9");
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-nudge-"));
|
|
121
|
+
cachePath = path.join(tmpdir, "version-check.json");
|
|
122
|
+
stderr = captureStream();
|
|
123
|
+
fetchOk.mockClear();
|
|
124
|
+
});
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
fs.rmSync(tmpdir, { recursive: true, force: true });
|
|
127
|
+
});
|
|
128
|
+
function baseDeps(over = {}) {
|
|
129
|
+
return {
|
|
130
|
+
currentVersion: "0.1.65",
|
|
131
|
+
cachePath,
|
|
132
|
+
now: 1_000_000,
|
|
133
|
+
env: {},
|
|
134
|
+
isTty: true,
|
|
135
|
+
commandName: "run",
|
|
136
|
+
stderr: stderr.stream,
|
|
137
|
+
fetchLatestVersion: fetchOk,
|
|
138
|
+
...over,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
it("prints nudge when cache shows a newer release", async () => {
|
|
142
|
+
writeCache(cachePath, { latest: "0.1.66", checkedAt: 1_000_000 });
|
|
143
|
+
await maybeNudge(baseDeps());
|
|
144
|
+
expect(stderr.text).toMatch(/0\.1\.66 is available/);
|
|
145
|
+
expect(stderr.text).toMatch(/sweny upgrade/);
|
|
146
|
+
});
|
|
147
|
+
it("stays silent when cache is equal or lower than current", async () => {
|
|
148
|
+
writeCache(cachePath, { latest: "0.1.65", checkedAt: 1_000_000 });
|
|
149
|
+
await maybeNudge(baseDeps());
|
|
150
|
+
expect(stderr.text).toBe("");
|
|
151
|
+
});
|
|
152
|
+
it("refreshes the cache when stale and writes the fetched version", async () => {
|
|
153
|
+
writeCache(cachePath, { latest: "0.1.64", checkedAt: 0 });
|
|
154
|
+
await maybeNudge(baseDeps({ now: CACHE_TTL_MS + 10 }));
|
|
155
|
+
expect(fetchOk).toHaveBeenCalledWith("latest");
|
|
156
|
+
const after = readCache(cachePath);
|
|
157
|
+
expect(after?.latest).toBe("9.9.9");
|
|
158
|
+
expect(after?.checkedAt).toBe(CACHE_TTL_MS + 10);
|
|
159
|
+
});
|
|
160
|
+
it("refreshes when cache is missing and does not crash when fetch fails", async () => {
|
|
161
|
+
const fetchFail = vi.fn(async () => {
|
|
162
|
+
throw new Error("ENETDOWN");
|
|
163
|
+
});
|
|
164
|
+
await maybeNudge(baseDeps({ fetchLatestVersion: fetchFail }));
|
|
165
|
+
expect(fetchFail).toHaveBeenCalled();
|
|
166
|
+
expect(readCache(cachePath)).toBeNull(); // fetch failed, nothing written
|
|
167
|
+
expect(stderr.text).toBe(""); // silent on error
|
|
168
|
+
});
|
|
169
|
+
it("does not refresh when cache is fresh (saves bandwidth)", async () => {
|
|
170
|
+
writeCache(cachePath, { latest: "0.1.65", checkedAt: 1_000_000 });
|
|
171
|
+
await maybeNudge(baseDeps({ now: 1_000_000 + 1 }));
|
|
172
|
+
expect(fetchOk).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
it("skips entirely in CI — no nudge, no fetch", async () => {
|
|
175
|
+
writeCache(cachePath, { latest: "0.1.66", checkedAt: 1_000_000 });
|
|
176
|
+
await maybeNudge(baseDeps({ env: { CI: "1" } }));
|
|
177
|
+
expect(stderr.text).toBe("");
|
|
178
|
+
expect(fetchOk).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
it("skips entirely on non-TTY stderr", async () => {
|
|
181
|
+
writeCache(cachePath, { latest: "0.1.66", checkedAt: 1_000_000 });
|
|
182
|
+
await maybeNudge(baseDeps({ isTty: false }));
|
|
183
|
+
expect(stderr.text).toBe("");
|
|
184
|
+
expect(fetchOk).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
it("suppresses the nudge from within `sweny upgrade` itself", async () => {
|
|
187
|
+
writeCache(cachePath, { latest: "0.1.66", checkedAt: 1_000_000 });
|
|
188
|
+
await maybeNudge(baseDeps({ commandName: "upgrade" }));
|
|
189
|
+
expect(stderr.text).toBe("");
|
|
190
|
+
expect(fetchOk).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
});
|
package/dist/cli/main.js
CHANGED
|
@@ -31,6 +31,9 @@ import { checkProviderConnectivity } from "./check.js";
|
|
|
31
31
|
import { registerSetupCommand } from "./setup.js";
|
|
32
32
|
import { registerPublishCommand } from "./publish.js";
|
|
33
33
|
import { reportToCloud } from "./cloud-report.js";
|
|
34
|
+
import { runUpgrade, fetchLatestFromNpm } from "./upgrade.js";
|
|
35
|
+
import { maybeNudge, defaultCachePath } from "./version-check.js";
|
|
36
|
+
import { spawnSync } from "node:child_process";
|
|
34
37
|
// ── Stream observer (NDJSON) ────────────────────────────────────────
|
|
35
38
|
/**
|
|
36
39
|
* Create an observer that writes NDJSON ExecutionEvents to stdout.
|
|
@@ -961,4 +964,65 @@ workflowCmd
|
|
|
961
964
|
}
|
|
962
965
|
console.log();
|
|
963
966
|
});
|
|
964
|
-
|
|
967
|
+
// ── sweny upgrade / update ────────────────────────────────────────────
|
|
968
|
+
// Self-update the globally-installed @sweny-ai/core. Mirrors the UX of
|
|
969
|
+
// `bun upgrade`, `deno upgrade`, `rustup update`, etc.
|
|
970
|
+
const upgradeCmd = program
|
|
971
|
+
.command("upgrade")
|
|
972
|
+
.alias("update")
|
|
973
|
+
.description("Upgrade sweny to the latest published version")
|
|
974
|
+
.option("--check", "Report what would be installed without running the installer")
|
|
975
|
+
.option("--force", "Reinstall even if the current version is already latest")
|
|
976
|
+
.option("--tag <tag>", "npm dist-tag to install (default: latest)", "latest")
|
|
977
|
+
.action(async (options) => {
|
|
978
|
+
// process.argv[1] is the CLI entrypoint — resolve through any symlinks
|
|
979
|
+
// (nvm, homebrew, etc. symlink the bin into a PATH dir) so PM detection
|
|
980
|
+
// looks at the real install location.
|
|
981
|
+
const argv1 = process.argv[1] ?? "";
|
|
982
|
+
let installPath = argv1;
|
|
983
|
+
try {
|
|
984
|
+
installPath = fs.realpathSync(argv1);
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
// Fall through with the unresolved path; detection degrades gracefully.
|
|
988
|
+
}
|
|
989
|
+
await runUpgrade(options, {
|
|
990
|
+
currentVersion: version,
|
|
991
|
+
installPath,
|
|
992
|
+
fetchLatestVersion: fetchLatestFromNpm,
|
|
993
|
+
runInstall: (cmd, args) => {
|
|
994
|
+
const res = spawnSync(cmd, args, { stdio: "inherit" });
|
|
995
|
+
if (res.error) {
|
|
996
|
+
process.stderr.write(chalk.red(` Error: couldn't run ${cmd} — ${res.error.message}`) + "\n");
|
|
997
|
+
return 127;
|
|
998
|
+
}
|
|
999
|
+
return typeof res.status === "number" ? res.status : 1;
|
|
1000
|
+
},
|
|
1001
|
+
canWrite: (dir) => {
|
|
1002
|
+
try {
|
|
1003
|
+
fs.accessSync(dir, fs.constants.W_OK);
|
|
1004
|
+
return true;
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
1009
|
+
},
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
// Touch upgradeCmd so TS/ESLint don't flag it unused if someone tightens rules.
|
|
1013
|
+
void upgradeCmd;
|
|
1014
|
+
// ── Passive "new version available" nudge ─────────────────────────────
|
|
1015
|
+
// Runs after every command. Non-blocking, bounded to 1.5s, silent on any
|
|
1016
|
+
// failure, skipped in CI / non-TTY / opt-out environments.
|
|
1017
|
+
program.hook("postAction", async (_thisCommand, actionCommand) => {
|
|
1018
|
+
const topLevel = actionCommand.parent?.name() === "sweny" ? actionCommand.name() : actionCommand.parent?.name();
|
|
1019
|
+
await maybeNudge({
|
|
1020
|
+
currentVersion: version,
|
|
1021
|
+
cachePath: defaultCachePath(),
|
|
1022
|
+
now: Date.now(),
|
|
1023
|
+
env: process.env,
|
|
1024
|
+
isTty: Boolean(process.stderr.isTTY),
|
|
1025
|
+
commandName: topLevel,
|
|
1026
|
+
});
|
|
1027
|
+
});
|
|
1028
|
+
await program.parseAsync();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweny upgrade` — self-update the globally-installed `@sweny-ai/core` CLI.
|
|
3
|
+
*
|
|
4
|
+
* The hard parts are detection (which package manager owns this install?) and
|
|
5
|
+
* permissions (global node_modules often require sudo). We keep the runtime
|
|
6
|
+
* surface tiny via dependency injection so the whole flow is testable.
|
|
7
|
+
*/
|
|
8
|
+
export type PackageManager = "npm" | "pnpm" | "yarn" | "bun" | "volta" | "brew" | "unknown";
|
|
9
|
+
export interface UpgradeOptions {
|
|
10
|
+
/** Skip the "already latest" short-circuit. */
|
|
11
|
+
force?: boolean;
|
|
12
|
+
/** Don't install — just report what would happen. */
|
|
13
|
+
check?: boolean;
|
|
14
|
+
/** Override the tag pulled from the registry. Defaults to "latest". */
|
|
15
|
+
tag?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface UpgradeDeps {
|
|
18
|
+
/** Version currently running (from `package.json`). */
|
|
19
|
+
currentVersion: string;
|
|
20
|
+
/** Absolute path to the current CLI entrypoint — used for PM detection. */
|
|
21
|
+
installPath: string;
|
|
22
|
+
/** Fetch latest version for `@sweny-ai/core@<tag>`. */
|
|
23
|
+
fetchLatestVersion: (tag: string) => Promise<string>;
|
|
24
|
+
/** Run the resolved install command. Returns the process exit code. */
|
|
25
|
+
runInstall: (cmd: string, args: string[]) => number;
|
|
26
|
+
/** Writable-dir probe for EACCES detection. */
|
|
27
|
+
canWrite: (dir: string) => boolean;
|
|
28
|
+
stdout?: NodeJS.WritableStream;
|
|
29
|
+
stderr?: NodeJS.WritableStream;
|
|
30
|
+
exit?: (code: number) => void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Detect the package manager that owns this install by walking the parent
|
|
34
|
+
* chain of the CLI entrypoint. The rules are ordered by specificity — more
|
|
35
|
+
* distinctive roots (bun, volta, pnpm, homebrew) beat the generic
|
|
36
|
+
* `lib/node_modules` npm layout.
|
|
37
|
+
*/
|
|
38
|
+
export declare function detectPackageManager(installPath: string): PackageManager;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the install-root directory for writability checks. For npm-style
|
|
41
|
+
* layouts, walk up until we find `node_modules/@sweny-ai/core` and return
|
|
42
|
+
* the parent of `node_modules` (the writable prefix). Returns null if the
|
|
43
|
+
* install path doesn't look like a package-manager install.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveInstallRoot(installPath: string): string | null;
|
|
46
|
+
/** Build the (cmd, args) pair for a given package manager + tag. */
|
|
47
|
+
export declare function installCommandFor(pm: PackageManager, tag: string): {
|
|
48
|
+
cmd: string;
|
|
49
|
+
args: string[];
|
|
50
|
+
} | null;
|
|
51
|
+
/**
|
|
52
|
+
* Compare two semver-ish version strings. Returns:
|
|
53
|
+
* - `>0` when `a > b`
|
|
54
|
+
* - `<0` when `a < b`
|
|
55
|
+
* - `0` when equal
|
|
56
|
+
*
|
|
57
|
+
* Handles numeric core (`major.minor.patch`) and pre-release strings
|
|
58
|
+
* conservatively: any pre-release (`-alpha.1`) is treated as *lower* than
|
|
59
|
+
* the same core without one, matching npm's semver ordering at the level
|
|
60
|
+
* of precision we need for "is the registry newer than me."
|
|
61
|
+
*/
|
|
62
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
63
|
+
/**
|
|
64
|
+
* Fetch the latest published version of `@sweny-ai/core` from the npm
|
|
65
|
+
* registry. Uses the dist-tag endpoint which returns a small, cacheable JSON
|
|
66
|
+
* document — no tarball download.
|
|
67
|
+
*/
|
|
68
|
+
export declare function fetchLatestFromNpm(tag: string): Promise<string>;
|
|
69
|
+
/**
|
|
70
|
+
* Main `sweny upgrade` entrypoint. Resolves the latest version, decides
|
|
71
|
+
* whether an install is needed, dispatches to the right package manager,
|
|
72
|
+
* and surfaces permission problems as actionable copy-paste commands.
|
|
73
|
+
*/
|
|
74
|
+
export declare function runUpgrade(options: UpgradeOptions, deps: UpgradeDeps): Promise<void>;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
/**
|
|
4
|
+
* Detect the package manager that owns this install by walking the parent
|
|
5
|
+
* chain of the CLI entrypoint. The rules are ordered by specificity — more
|
|
6
|
+
* distinctive roots (bun, volta, pnpm, homebrew) beat the generic
|
|
7
|
+
* `lib/node_modules` npm layout.
|
|
8
|
+
*/
|
|
9
|
+
export function detectPackageManager(installPath) {
|
|
10
|
+
// Normalize to forward slashes so Windows paths match the same patterns.
|
|
11
|
+
const p = installPath.replace(/\\/g, "/").toLowerCase();
|
|
12
|
+
if (p.includes("/homebrew/") || p.includes("/cellar/"))
|
|
13
|
+
return "brew";
|
|
14
|
+
if (p.includes("/.volta/"))
|
|
15
|
+
return "volta";
|
|
16
|
+
if (p.includes("/.bun/"))
|
|
17
|
+
return "bun";
|
|
18
|
+
// pnpm global store lives under ~/.local/share/pnpm or ~/Library/pnpm, and
|
|
19
|
+
// the actual binary is a symlink — the resolved path typically contains
|
|
20
|
+
// either `/pnpm/` or the global store marker `/pnpm-global/`.
|
|
21
|
+
if (p.includes("/pnpm/") || p.includes("/pnpm-global/") || p.includes("/.pnpm-store/"))
|
|
22
|
+
return "pnpm";
|
|
23
|
+
// Yarn's global install folder is ~/.config/yarn/global or
|
|
24
|
+
// ~/.yarn/global — check for both.
|
|
25
|
+
if (p.includes("/.yarn/") || p.includes("/yarn/global/"))
|
|
26
|
+
return "yarn";
|
|
27
|
+
// Default: assume npm if we see node_modules — covers nvm, fnm, asdf,
|
|
28
|
+
// vanilla Node, and every CI runner.
|
|
29
|
+
if (p.includes("/node_modules/"))
|
|
30
|
+
return "npm";
|
|
31
|
+
return "unknown";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the install-root directory for writability checks. For npm-style
|
|
35
|
+
* layouts, walk up until we find `node_modules/@sweny-ai/core` and return
|
|
36
|
+
* the parent of `node_modules` (the writable prefix). Returns null if the
|
|
37
|
+
* install path doesn't look like a package-manager install.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveInstallRoot(installPath) {
|
|
40
|
+
let cur = path.dirname(installPath);
|
|
41
|
+
const visited = new Set();
|
|
42
|
+
while (cur && !visited.has(cur) && path.dirname(cur) !== cur) {
|
|
43
|
+
visited.add(cur);
|
|
44
|
+
if (path.basename(cur) === "@sweny-ai") {
|
|
45
|
+
// cur = .../node_modules/@sweny-ai — parent of node_modules is the root
|
|
46
|
+
const nodeModules = path.dirname(cur);
|
|
47
|
+
if (path.basename(nodeModules) === "node_modules") {
|
|
48
|
+
return path.dirname(nodeModules);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
cur = path.dirname(cur);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/** Build the (cmd, args) pair for a given package manager + tag. */
|
|
56
|
+
export function installCommandFor(pm, tag) {
|
|
57
|
+
const ref = `@sweny-ai/core@${tag}`;
|
|
58
|
+
switch (pm) {
|
|
59
|
+
case "npm":
|
|
60
|
+
return { cmd: "npm", args: ["install", "-g", ref] };
|
|
61
|
+
case "pnpm":
|
|
62
|
+
return { cmd: "pnpm", args: ["add", "-g", ref] };
|
|
63
|
+
case "yarn":
|
|
64
|
+
return { cmd: "yarn", args: ["global", "add", ref] };
|
|
65
|
+
case "bun":
|
|
66
|
+
return { cmd: "bun", args: ["add", "-g", ref] };
|
|
67
|
+
case "volta":
|
|
68
|
+
return { cmd: "volta", args: ["install", `@sweny-ai/core@${tag}`] };
|
|
69
|
+
case "brew":
|
|
70
|
+
case "unknown":
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Compare two semver-ish version strings. Returns:
|
|
76
|
+
* - `>0` when `a > b`
|
|
77
|
+
* - `<0` when `a < b`
|
|
78
|
+
* - `0` when equal
|
|
79
|
+
*
|
|
80
|
+
* Handles numeric core (`major.minor.patch`) and pre-release strings
|
|
81
|
+
* conservatively: any pre-release (`-alpha.1`) is treated as *lower* than
|
|
82
|
+
* the same core without one, matching npm's semver ordering at the level
|
|
83
|
+
* of precision we need for "is the registry newer than me."
|
|
84
|
+
*/
|
|
85
|
+
export function compareVersions(a, b) {
|
|
86
|
+
const parse = (v) => {
|
|
87
|
+
const [core, pre] = v.split("-", 2);
|
|
88
|
+
const nums = core.split(".").map((n) => {
|
|
89
|
+
const parsed = Number.parseInt(n, 10);
|
|
90
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
91
|
+
});
|
|
92
|
+
return { nums, pre: pre ?? "" };
|
|
93
|
+
};
|
|
94
|
+
const pa = parse(a);
|
|
95
|
+
const pb = parse(b);
|
|
96
|
+
const len = Math.max(pa.nums.length, pb.nums.length);
|
|
97
|
+
for (let i = 0; i < len; i++) {
|
|
98
|
+
const av = pa.nums[i] ?? 0;
|
|
99
|
+
const bv = pb.nums[i] ?? 0;
|
|
100
|
+
if (av !== bv)
|
|
101
|
+
return av - bv;
|
|
102
|
+
}
|
|
103
|
+
// Core equal — a pre-release ranks lower than a release.
|
|
104
|
+
if (pa.pre && !pb.pre)
|
|
105
|
+
return -1;
|
|
106
|
+
if (!pa.pre && pb.pre)
|
|
107
|
+
return 1;
|
|
108
|
+
if (pa.pre < pb.pre)
|
|
109
|
+
return -1;
|
|
110
|
+
if (pa.pre > pb.pre)
|
|
111
|
+
return 1;
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Fetch the latest published version of `@sweny-ai/core` from the npm
|
|
116
|
+
* registry. Uses the dist-tag endpoint which returns a small, cacheable JSON
|
|
117
|
+
* document — no tarball download.
|
|
118
|
+
*/
|
|
119
|
+
export async function fetchLatestFromNpm(tag) {
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetch(`https://registry.npmjs.org/@sweny-ai/core/${encodeURIComponent(tag)}`, {
|
|
124
|
+
signal: controller.signal,
|
|
125
|
+
headers: { accept: "application/json" },
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
throw new Error(`npm registry returned ${res.status} ${res.statusText}`);
|
|
129
|
+
}
|
|
130
|
+
const body = (await res.json());
|
|
131
|
+
if (typeof body.version !== "string" || body.version.length === 0) {
|
|
132
|
+
throw new Error("npm registry response missing `version` field");
|
|
133
|
+
}
|
|
134
|
+
return body.version;
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
clearTimeout(timeout);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const BREW_HINT = "This CLI looks like it was installed via Homebrew. Run `brew upgrade sweny` or `brew update && brew upgrade sweny`.";
|
|
141
|
+
const UNKNOWN_HINT = "Couldn't detect your package manager. Install the latest manually with:\n npm install -g @sweny-ai/core@latest";
|
|
142
|
+
/**
|
|
143
|
+
* Main `sweny upgrade` entrypoint. Resolves the latest version, decides
|
|
144
|
+
* whether an install is needed, dispatches to the right package manager,
|
|
145
|
+
* and surfaces permission problems as actionable copy-paste commands.
|
|
146
|
+
*/
|
|
147
|
+
export async function runUpgrade(options, deps) {
|
|
148
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
149
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
150
|
+
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
151
|
+
const tag = options.tag && options.tag.length > 0 ? options.tag : "latest";
|
|
152
|
+
let latest;
|
|
153
|
+
try {
|
|
154
|
+
latest = await deps.fetchLatestVersion(tag);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
stderr.write(chalk.red(` Error: couldn't reach the npm registry — ${err instanceof Error ? err.message : String(err)}`) +
|
|
158
|
+
"\n");
|
|
159
|
+
stderr.write(chalk.dim(" Check your network connection and try again.") + "\n");
|
|
160
|
+
exit(1);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const current = deps.currentVersion;
|
|
164
|
+
const cmp = compareVersions(current, latest);
|
|
165
|
+
if (cmp >= 0 && !options.force) {
|
|
166
|
+
if (cmp === 0) {
|
|
167
|
+
stdout.write(chalk.green(` ✓ sweny ${current} is already the latest.`) + "\n");
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
stdout.write(chalk.yellow(` You're running sweny ${current}, which is newer than the published ${latest}. Nothing to do.`) +
|
|
171
|
+
"\n");
|
|
172
|
+
}
|
|
173
|
+
exit(0);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const pm = detectPackageManager(deps.installPath);
|
|
177
|
+
// Homebrew has its own update cadence — we don't try to drive it from here.
|
|
178
|
+
if (pm === "brew") {
|
|
179
|
+
stdout.write(chalk.cyan(` sweny ${latest} is available (you have ${current}).`) + "\n");
|
|
180
|
+
stderr.write(chalk.dim(` ${BREW_HINT}`) + "\n");
|
|
181
|
+
exit(options.check ? 0 : 1);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const installCmd = installCommandFor(pm, tag);
|
|
185
|
+
if (!installCmd) {
|
|
186
|
+
stdout.write(chalk.cyan(` sweny ${latest} is available (you have ${current}).`) + "\n");
|
|
187
|
+
stderr.write(chalk.dim(` ${UNKNOWN_HINT}`) + "\n");
|
|
188
|
+
exit(options.check ? 0 : 1);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const fullCmd = [installCmd.cmd, ...installCmd.args].join(" ");
|
|
192
|
+
if (options.check) {
|
|
193
|
+
stdout.write(chalk.cyan(` sweny ${latest} is available (you have ${current}).`) + "\n");
|
|
194
|
+
stdout.write(chalk.dim(` Run: ${fullCmd}`) + "\n");
|
|
195
|
+
exit(0);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// Permissions probe — for npm-style layouts, the user needs write access to
|
|
199
|
+
// the prefix. If not, we print the exact sudo-prefixed command so they can
|
|
200
|
+
// copy-paste without guessing.
|
|
201
|
+
const root = resolveInstallRoot(deps.installPath);
|
|
202
|
+
if (root && !deps.canWrite(root)) {
|
|
203
|
+
stderr.write(chalk.red(` Error: no write permission on ${root}`) + "\n");
|
|
204
|
+
stderr.write(chalk.dim(` Try: sudo ${fullCmd}`) + "\n");
|
|
205
|
+
stderr.write(chalk.dim(` Or reinstall Node with a user-writable prefix (nvm, fnm, volta) to avoid sudo.`) + "\n");
|
|
206
|
+
exit(1);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
stdout.write(chalk.cyan(` Upgrading sweny ${current} → ${latest} via ${pm}…`) + "\n");
|
|
210
|
+
stdout.write(chalk.dim(` $ ${fullCmd}`) + "\n");
|
|
211
|
+
const code = deps.runInstall(installCmd.cmd, installCmd.args);
|
|
212
|
+
if (code !== 0) {
|
|
213
|
+
stderr.write(chalk.red(` Error: ${installCmd.cmd} exited with code ${code}`) + "\n");
|
|
214
|
+
exit(code);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
stdout.write(chalk.green(` ✓ Upgraded sweny to ${latest}.`) + "\n");
|
|
218
|
+
exit(0);
|
|
219
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passive "a new sweny is out" nudge. Runs as a commander `postAction` hook
|
|
3
|
+
* so every command prints at most one dim footer line when behind.
|
|
4
|
+
*
|
|
5
|
+
* Design goals:
|
|
6
|
+
* 1. Never block the user's command. Cache reads are sync; network refresh
|
|
7
|
+
* happens inline but bounded to a tight budget — if the registry is slow
|
|
8
|
+
* we swallow the error silently and try again in 24h.
|
|
9
|
+
* 2. Never nag in contexts where nobody's watching — CI, pipes, or when the
|
|
10
|
+
* user has explicitly opted out.
|
|
11
|
+
* 3. Never fire from inside `sweny upgrade` itself (would double-print the
|
|
12
|
+
* upgrade hint right before/after running the install).
|
|
13
|
+
*/
|
|
14
|
+
export declare const CACHE_TTL_MS: number;
|
|
15
|
+
/** Budget for the background registry refresh. Short on purpose. */
|
|
16
|
+
export declare const REFRESH_BUDGET_MS = 1500;
|
|
17
|
+
export interface CacheEntry {
|
|
18
|
+
latest: string;
|
|
19
|
+
checkedAt: number;
|
|
20
|
+
}
|
|
21
|
+
export interface VersionCheckDeps {
|
|
22
|
+
currentVersion: string;
|
|
23
|
+
cachePath: string;
|
|
24
|
+
now: number;
|
|
25
|
+
env: NodeJS.ProcessEnv;
|
|
26
|
+
isTty: boolean;
|
|
27
|
+
/** The command the user ran, so we can suppress the nudge for self-referential commands. */
|
|
28
|
+
commandName?: string;
|
|
29
|
+
stderr?: NodeJS.WritableStream;
|
|
30
|
+
fetchLatestVersion?: (tag: string) => Promise<string>;
|
|
31
|
+
/** Injected so tests can make the refresh synchronous + deterministic. */
|
|
32
|
+
now_fn?: () => number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Decide whether any version-check work should run at all. We bail early for:
|
|
36
|
+
* - `CI=1`: noise in pipelines and reads tokens
|
|
37
|
+
* - `SWENY_NO_UPDATE_CHECK=1`: explicit opt-out
|
|
38
|
+
* - `SWENY_OFFLINE=1`: users behind airgaps
|
|
39
|
+
* - Non-TTY stderr: scripts and pipes
|
|
40
|
+
* - `upgrade` / `update` commands: the command itself handles the hint
|
|
41
|
+
*/
|
|
42
|
+
export declare function shouldSkip(env: NodeJS.ProcessEnv, isTty: boolean, commandName: string | undefined): boolean;
|
|
43
|
+
export declare function readCache(cachePath: string): CacheEntry | null;
|
|
44
|
+
export declare function writeCache(cachePath: string, entry: CacheEntry): void;
|
|
45
|
+
export declare function isCacheFresh(entry: CacheEntry, now: number, ttl?: number): boolean;
|
|
46
|
+
export declare function formatNudge(current: string, latest: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the cache path on disk. Honors `XDG_CACHE_HOME` and falls back to
|
|
49
|
+
* `~/.cache/sweny/version-check.json`. We isolate this so tests can inject a
|
|
50
|
+
* tmpdir without touching the real cache.
|
|
51
|
+
*/
|
|
52
|
+
export declare function defaultCachePath(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Run the check. Prints a nudge if the cache shows a newer version, then
|
|
55
|
+
* refreshes the cache in the background if it's stale or missing.
|
|
56
|
+
*
|
|
57
|
+
* The refresh is awaited with a short timeout so we don't hang the CLI, but
|
|
58
|
+
* we also don't surface its result on *this* run — that keeps the code paths
|
|
59
|
+
* simple and avoids "first run after install" printing a nudge before the
|
|
60
|
+
* user's command finishes rendering.
|
|
61
|
+
*/
|
|
62
|
+
export declare function maybeNudge(deps: VersionCheckDeps): Promise<void>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { compareVersions, fetchLatestFromNpm } from "./upgrade.js";
|
|
6
|
+
/**
|
|
7
|
+
* Passive "a new sweny is out" nudge. Runs as a commander `postAction` hook
|
|
8
|
+
* so every command prints at most one dim footer line when behind.
|
|
9
|
+
*
|
|
10
|
+
* Design goals:
|
|
11
|
+
* 1. Never block the user's command. Cache reads are sync; network refresh
|
|
12
|
+
* happens inline but bounded to a tight budget — if the registry is slow
|
|
13
|
+
* we swallow the error silently and try again in 24h.
|
|
14
|
+
* 2. Never nag in contexts where nobody's watching — CI, pipes, or when the
|
|
15
|
+
* user has explicitly opted out.
|
|
16
|
+
* 3. Never fire from inside `sweny upgrade` itself (would double-print the
|
|
17
|
+
* upgrade hint right before/after running the install).
|
|
18
|
+
*/
|
|
19
|
+
export const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
20
|
+
/** Budget for the background registry refresh. Short on purpose. */
|
|
21
|
+
export const REFRESH_BUDGET_MS = 1500;
|
|
22
|
+
/**
|
|
23
|
+
* Decide whether any version-check work should run at all. We bail early for:
|
|
24
|
+
* - `CI=1`: noise in pipelines and reads tokens
|
|
25
|
+
* - `SWENY_NO_UPDATE_CHECK=1`: explicit opt-out
|
|
26
|
+
* - `SWENY_OFFLINE=1`: users behind airgaps
|
|
27
|
+
* - Non-TTY stderr: scripts and pipes
|
|
28
|
+
* - `upgrade` / `update` commands: the command itself handles the hint
|
|
29
|
+
*/
|
|
30
|
+
export function shouldSkip(env, isTty, commandName) {
|
|
31
|
+
if (env.CI)
|
|
32
|
+
return true;
|
|
33
|
+
if (env.SWENY_NO_UPDATE_CHECK)
|
|
34
|
+
return true;
|
|
35
|
+
if (env.SWENY_OFFLINE)
|
|
36
|
+
return true;
|
|
37
|
+
if (!isTty)
|
|
38
|
+
return true;
|
|
39
|
+
if (commandName === "upgrade" || commandName === "update")
|
|
40
|
+
return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
export function readCache(cachePath) {
|
|
44
|
+
try {
|
|
45
|
+
const raw = fs.readFileSync(cachePath, "utf8");
|
|
46
|
+
const parsed = JSON.parse(raw);
|
|
47
|
+
if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "number") {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (parsed.latest.length === 0)
|
|
51
|
+
return null;
|
|
52
|
+
return { latest: parsed.latest, checkedAt: parsed.checkedAt };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function writeCache(cachePath, entry) {
|
|
59
|
+
try {
|
|
60
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
61
|
+
fs.writeFileSync(cachePath, JSON.stringify(entry));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Cache-write failures are non-fatal — we'll just retry next run.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function isCacheFresh(entry, now, ttl = CACHE_TTL_MS) {
|
|
68
|
+
return now - entry.checkedAt < ttl;
|
|
69
|
+
}
|
|
70
|
+
export function formatNudge(current, latest) {
|
|
71
|
+
return chalk.dim(` › sweny ${latest} is available (you have ${current}). Run \`sweny upgrade\``);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the cache path on disk. Honors `XDG_CACHE_HOME` and falls back to
|
|
75
|
+
* `~/.cache/sweny/version-check.json`. We isolate this so tests can inject a
|
|
76
|
+
* tmpdir without touching the real cache.
|
|
77
|
+
*/
|
|
78
|
+
export function defaultCachePath(env = process.env, home = os.homedir()) {
|
|
79
|
+
const xdg = env.XDG_CACHE_HOME && env.XDG_CACHE_HOME.length > 0 ? env.XDG_CACHE_HOME : path.join(home, ".cache");
|
|
80
|
+
return path.join(xdg, "sweny", "version-check.json");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Run the check. Prints a nudge if the cache shows a newer version, then
|
|
84
|
+
* refreshes the cache in the background if it's stale or missing.
|
|
85
|
+
*
|
|
86
|
+
* The refresh is awaited with a short timeout so we don't hang the CLI, but
|
|
87
|
+
* we also don't surface its result on *this* run — that keeps the code paths
|
|
88
|
+
* simple and avoids "first run after install" printing a nudge before the
|
|
89
|
+
* user's command finishes rendering.
|
|
90
|
+
*/
|
|
91
|
+
export async function maybeNudge(deps) {
|
|
92
|
+
if (shouldSkip(deps.env, deps.isTty, deps.commandName))
|
|
93
|
+
return;
|
|
94
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
95
|
+
const cache = readCache(deps.cachePath);
|
|
96
|
+
if (cache && compareVersions(deps.currentVersion, cache.latest) < 0) {
|
|
97
|
+
stderr.write(formatNudge(deps.currentVersion, cache.latest) + "\n");
|
|
98
|
+
}
|
|
99
|
+
if (cache && isCacheFresh(cache, deps.now))
|
|
100
|
+
return;
|
|
101
|
+
// Stale or missing — refresh in the background with a hard timeout. We
|
|
102
|
+
// race the fetch against a timer so the process can exit promptly even if
|
|
103
|
+
// the registry hangs.
|
|
104
|
+
const fetchFn = deps.fetchLatestVersion ?? fetchLatestFromNpm;
|
|
105
|
+
try {
|
|
106
|
+
const latest = await Promise.race([
|
|
107
|
+
fetchFn("latest").catch(() => null),
|
|
108
|
+
new Promise((resolve) => {
|
|
109
|
+
const t = setTimeout(() => resolve(null), REFRESH_BUDGET_MS);
|
|
110
|
+
// Don't keep the event loop alive purely for this timer.
|
|
111
|
+
if (typeof t.unref === "function") {
|
|
112
|
+
t.unref();
|
|
113
|
+
}
|
|
114
|
+
}),
|
|
115
|
+
]);
|
|
116
|
+
if (latest) {
|
|
117
|
+
writeCache(deps.cachePath, { latest, checkedAt: deps.now });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Swallow — network hiccups shouldn't be user-visible here.
|
|
122
|
+
}
|
|
123
|
+
}
|