Skip to content

Commit 3ca8155

Browse files
authored
Add auto-update (#3)
1 parent dd43ecf commit 3ca8155

File tree

5 files changed

+334
-2
lines changed

5 files changed

+334
-2
lines changed

script/publish.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env bun
2+
3+
import { $ } from "bun";
4+
5+
const bump = process.env.BUMP;
6+
if (!bump || !["patch", "minor", "major"].includes(bump)) {
7+
console.error(
8+
"Invalid or missing BUMP environment variable. Must be patch, minor, or major.",
9+
);
10+
process.exit(1);
11+
}
12+
13+
const pkg = await Bun.file("package.json").json();
14+
const currentVersion = pkg.version;
15+
console.log(`Current version: ${currentVersion}`);
16+
17+
const [major, minor, patch] = currentVersion.split(".").map(Number);
18+
const newVersion = (() => {
19+
switch (bump) {
20+
case "patch":
21+
return `${major}.${minor}.${patch + 1}`;
22+
case "minor":
23+
return `${major}.${minor + 1}.0`;
24+
case "major":
25+
return `${major + 1}.0.0`;
26+
default:
27+
throw new Error(`Invalid bump type: ${bump}`);
28+
}
29+
})();
30+
31+
console.log(`New version: ${newVersion}`);
32+
33+
pkg.version = newVersion;
34+
await Bun.file("package.json").write(`${JSON.stringify(pkg, null, 2)}\n`);
35+
console.log("Updated package.json");
36+
37+
console.log("Building project...");
38+
await $`bun run build`;
39+
console.log("Build completed");
40+
41+
console.log("Authenticating with npm...");
42+
await $`npm config set //registry.npmjs.org/:_authToken ${process.env.NPM_TOKEN}`;
43+
44+
console.log("Publishing to npm...");
45+
await $`npm publish --access public`;
46+
console.log("Published to npm");
47+
48+
const output = `version=${newVersion}\ntag=v${newVersion}\n`;
49+
if (process.env.GITHUB_OUTPUT) {
50+
await Bun.write(process.env.GITHUB_OUTPUT, output);
51+
}
52+
53+
console.log(`Successfully published v${newVersion}!`);

scripts/execute-tests.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,7 @@ async function main(): Promise<void> {
8181
let testFiles: string[];
8282
try {
8383
testFiles = await findTestFiles(opts.testsDir);
84-
} catch (err) {
85-
const _msg = err instanceof Error ? err.message : String(err);
84+
} catch {
8685
process.exit(1);
8786
}
8887

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { pushCommand } from "./commands/push";
1111
import { statusCommand } from "./commands/status";
1212
import { syncCommand } from "./commands/sync";
1313
import { unsyncCommand } from "./commands/unsync";
14+
import { checkForUpdates } from "./utils/update-check";
1415

1516
// Read version from package.json
1617
const __filename = fileURLToPath(import.meta.url);
@@ -33,6 +34,11 @@ const commands = {
3334
async function main() {
3435
const args = process.argv.slice(2);
3536

37+
await checkForUpdates({
38+
currentVersion: VERSION,
39+
packageName: packageJson.name,
40+
});
41+
3642
// Handle --version flag
3743
if (args.includes("--version") || args.includes("-v")) {
3844
console.log(`syncode v${VERSION}`);

src/utils/update-check.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { clearTimeout, setTimeout } from "node:timers";
2+
3+
export interface UpdateCheckOptions {
4+
currentVersion: string;
5+
packageName: string;
6+
}
7+
8+
const UPDATE_TIMEOUT_MS = 2000;
9+
10+
export function isNpxInvocation() {
11+
const argv1 = process.argv[1] || "";
12+
const execPath = process.env.npm_execpath || "";
13+
const userAgent = process.env.npm_config_user_agent || "";
14+
const npxPathMatch = argv1.includes("/_npx/") || argv1.includes("\\_npx\\");
15+
const execMatch = execPath.includes("npx") || execPath.includes("npx-cli");
16+
const agentMatch = userAgent.includes("npx");
17+
return npxPathMatch || execMatch || agentMatch;
18+
}
19+
20+
export function parseVersion(version: string) {
21+
const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)$/);
22+
if (!match) return null;
23+
return [Number(match[1]), Number(match[2]), Number(match[3])];
24+
}
25+
26+
export function isOutdated(current: string, latest: string) {
27+
const currentParts = parseVersion(current);
28+
const latestParts = parseVersion(latest);
29+
if (!currentParts || !latestParts) return false;
30+
for (let i = 0; i < 3; i += 1) {
31+
const currentValue = currentParts[i];
32+
const latestValue = latestParts[i];
33+
if (currentValue === undefined || latestValue === undefined) return false;
34+
if (latestValue > currentValue) return true;
35+
if (latestValue < currentValue) return false;
36+
}
37+
return false;
38+
}
39+
40+
export function formatUpdateNotice(params: {
41+
current: string;
42+
latest: string;
43+
packageName: string;
44+
}) {
45+
const updateCommand = `npm install -g ${params.packageName}@latest`;
46+
const lines = [
47+
"📦 A new version of Syncode is",
48+
"available!",
49+
"",
50+
`Current: ${params.current}`,
51+
`Latest: ${params.latest}`,
52+
"",
53+
"Run to update:",
54+
updateCommand,
55+
];
56+
57+
const contentWidth = Math.max(...lines.map((line) => line.length));
58+
const top = `┌${"─".repeat(contentWidth + 2)}┐`;
59+
const bottom = `└${"─".repeat(contentWidth + 2)}┘`;
60+
const boxed = lines
61+
.map((line) => `│ ${line.padEnd(contentWidth, " ")} │`)
62+
.join("\n");
63+
return `${top}\n${boxed}\n${bottom}`;
64+
}
65+
66+
async function fetchLatestVersion(packageName: string) {
67+
const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
68+
const controller = new AbortController();
69+
const timeout = setTimeout(() => controller.abort(), UPDATE_TIMEOUT_MS);
70+
71+
try {
72+
const response = await fetch(url, { signal: controller.signal });
73+
if (!response.ok) return null;
74+
const data = (await response.json()) as { version?: string };
75+
return typeof data.version === "string" ? data.version : null;
76+
} catch {
77+
return null;
78+
} finally {
79+
clearTimeout(timeout);
80+
}
81+
}
82+
83+
export async function checkForUpdates({
84+
currentVersion,
85+
packageName,
86+
}: UpdateCheckOptions) {
87+
if (isNpxInvocation()) return;
88+
89+
const latest = await fetchLatestVersion(packageName);
90+
if (!latest) return;
91+
92+
if (isOutdated(currentVersion, latest)) {
93+
console.log(
94+
formatUpdateNotice({
95+
current: currentVersion,
96+
latest,
97+
packageName,
98+
}),
99+
);
100+
}
101+
}

tests/update-check.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#!/usr/bin/env tsx
2+
3+
import assert from "node:assert/strict";
4+
import * as updateCheck from "../src/utils/update-check";
5+
6+
type TestCase = {
7+
name: string;
8+
run: () => void | Promise<void>;
9+
};
10+
11+
const tests: TestCase[] = [];
12+
const originalArgv1 = process.argv[1];
13+
const originalEnvExecPath = process.env.npm_execpath;
14+
const originalEnvUserAgent = process.env.npm_config_user_agent;
15+
16+
function test(name: string, run: TestCase["run"]) {
17+
tests.push({ name, run });
18+
}
19+
20+
function cleanup() {
21+
process.argv[1] = originalArgv1;
22+
process.env.npm_execpath = originalEnvExecPath ?? "";
23+
process.env.npm_config_user_agent = originalEnvUserAgent ?? "";
24+
}
25+
26+
test("parseVersion handles valid versions", () => {
27+
assert.deepStrictEqual(updateCheck.parseVersion("1.2.3"), [1, 2, 3]);
28+
assert.deepStrictEqual(updateCheck.parseVersion("0.0.0"), [0, 0, 0]);
29+
assert.deepStrictEqual(updateCheck.parseVersion("10.20.30"), [10, 20, 30]);
30+
});
31+
32+
test("parseVersion handles invalid versions", () => {
33+
assert.strictEqual(updateCheck.parseVersion("1.2"), null);
34+
assert.strictEqual(updateCheck.parseVersion("1.2.3.4"), null);
35+
assert.strictEqual(updateCheck.parseVersion("v1.2.3"), null);
36+
assert.strictEqual(updateCheck.parseVersion(""), null);
37+
assert.strictEqual(updateCheck.parseVersion("invalid"), null);
38+
});
39+
40+
test("isOutdated detects newer versions", () => {
41+
assert.strictEqual(updateCheck.isOutdated("1.0.0", "1.0.1"), true);
42+
assert.strictEqual(updateCheck.isOutdated("1.0.0", "1.1.0"), true);
43+
assert.strictEqual(updateCheck.isOutdated("1.0.0", "2.0.0"), true);
44+
assert.strictEqual(updateCheck.isOutdated("1.2.3", "1.2.4"), true);
45+
});
46+
47+
test("isOutdated returns false when up to date", () => {
48+
assert.strictEqual(updateCheck.isOutdated("1.0.0", "1.0.0"), false);
49+
assert.strictEqual(updateCheck.isOutdated("1.1.0", "1.0.0"), false);
50+
assert.strictEqual(updateCheck.isOutdated("1.0.1", "1.0.0"), false);
51+
assert.strictEqual(updateCheck.isOutdated("1.2.4", "1.2.3"), false);
52+
assert.strictEqual(updateCheck.isOutdated("2.0.0", "1.0.0"), false);
53+
});
54+
55+
test("isOutdated handles invalid versions gracefully", () => {
56+
assert.strictEqual(updateCheck.isOutdated("invalid", "1.0.0"), false);
57+
assert.strictEqual(updateCheck.isOutdated("1.0.0", "invalid"), false);
58+
assert.strictEqual(updateCheck.isOutdated("1.2", "1.2.4"), false);
59+
});
60+
61+
test("formatUpdateNotice creates boxed output", () => {
62+
const output = updateCheck.formatUpdateNotice({
63+
current: "1.0.0",
64+
latest: "1.1.0",
65+
packageName: "@donnes/syncode",
66+
});
67+
68+
assert.ok(output.includes("┌"));
69+
assert.ok(output.includes("┐"));
70+
assert.ok(output.includes("└"));
71+
assert.ok(output.includes("┘"));
72+
assert.ok(output.includes("│"));
73+
assert.ok(output.includes("─"));
74+
assert.ok(output.includes("Current: 1.0.0"));
75+
assert.ok(output.includes("Latest: 1.1.0"));
76+
assert.ok(output.includes("npm install -g @donnes/syncode@latest"));
77+
});
78+
79+
test("formatUpdateNotice handles different package names", () => {
80+
const output = updateCheck.formatUpdateNotice({
81+
current: "1.0.0",
82+
latest: "2.0.0",
83+
packageName: "test-package",
84+
});
85+
86+
assert.ok(output.includes("npm install -g test-package@latest"));
87+
});
88+
89+
test("isNpxInvocation detects npx execution", () => {
90+
const originalArgv1 = process.argv[1];
91+
try {
92+
process.argv[1] = "/tmp/_npx/package/lib/index.js";
93+
assert.strictEqual(updateCheck.isNpxInvocation(), true);
94+
95+
process.argv[1] = "C:\\Users\\_npx\\package\\lib\\index.js";
96+
assert.strictEqual(updateCheck.isNpxInvocation(), true);
97+
} finally {
98+
process.argv[1] = originalArgv1;
99+
}
100+
});
101+
102+
test("isNpxInvocation detects npx via npm_execpath", () => {
103+
const originalEnvNodeExecpath = process.env.npm_execpath;
104+
try {
105+
process.env.npm_execpath = "/usr/local/lib/node_modules/npm/bin/npx-cli.js";
106+
assert.strictEqual(updateCheck.isNpxInvocation(), true);
107+
108+
process.env.npm_execpath =
109+
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js";
110+
assert.strictEqual(updateCheck.isNpxInvocation(), true);
111+
} finally {
112+
process.env.npm_execpath = originalEnvNodeExecpath ?? "";
113+
}
114+
});
115+
116+
test("isNpxInvocation detects npx via user agent", () => {
117+
const originalEnvUserAgent = process.env.npm_config_user_agent;
118+
try {
119+
process.env.npm_config_user_agent = "npm/9.0.0 node/v18.0.0 npx/9.0.0";
120+
assert.strictEqual(updateCheck.isNpxInvocation(), true);
121+
} finally {
122+
process.env.npm_config_user_agent = originalEnvUserAgent ?? "";
123+
}
124+
});
125+
126+
test("isNpxInvocation returns false for global install", () => {
127+
const originalArgv1 = process.argv[1];
128+
const originalEnvNodeExecpath = process.env.npm_execpath;
129+
const originalEnvUserAgent = process.env.npm_config_user_agent;
130+
131+
try {
132+
process.argv[1] = "/usr/local/bin/syncode";
133+
process.env.npm_execpath = "";
134+
process.env.npm_config_user_agent = "";
135+
136+
assert.strictEqual(updateCheck.isNpxInvocation(), false);
137+
} finally {
138+
process.argv[1] = originalArgv1;
139+
process.env.npm_execpath = originalEnvNodeExecpath ?? "";
140+
process.env.npm_config_user_agent = originalEnvUserAgent ?? "";
141+
}
142+
});
143+
144+
test("isOutdated comparison edge cases", () => {
145+
assert.strictEqual(updateCheck.isOutdated("9.9.9", "10.0.0"), true);
146+
assert.strictEqual(updateCheck.isOutdated("10.0.0", "9.9.9"), false);
147+
assert.strictEqual(updateCheck.isOutdated("0.0.0", "0.0.1"), true);
148+
assert.strictEqual(updateCheck.isOutdated("1.9.9", "2.0.0"), true);
149+
assert.strictEqual(updateCheck.isOutdated("2.0.0", "2.0.1"), true);
150+
assert.strictEqual(updateCheck.isOutdated("2.0.0", "2.1.0"), true);
151+
assert.strictEqual(updateCheck.isOutdated("2.1.0", "3.0.0"), true);
152+
});
153+
154+
async function run() {
155+
for (const { name, run } of tests) {
156+
try {
157+
await run();
158+
console.log(`✓ ${name}`);
159+
} catch (error) {
160+
console.error(`✗ ${name}`);
161+
console.error(error);
162+
process.exitCode = 1;
163+
} finally {
164+
cleanup();
165+
}
166+
}
167+
168+
if (process.exitCode) {
169+
process.exit(1);
170+
}
171+
}
172+
173+
await run();

0 commit comments

Comments
 (0)