diff --git a/script/publish.ts b/script/publish.ts new file mode 100644 index 0000000..00e5cb1 --- /dev/null +++ b/script/publish.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env bun + +import { $ } from "bun"; + +const bump = process.env.BUMP; +if (!bump || !["patch", "minor", "major"].includes(bump)) { + console.error( + "Invalid or missing BUMP environment variable. Must be patch, minor, or major.", + ); + process.exit(1); +} + +const pkg = await Bun.file("package.json").json(); +const currentVersion = pkg.version; +console.log(`Current version: ${currentVersion}`); + +const [major, minor, patch] = currentVersion.split(".").map(Number); +const newVersion = (() => { + switch (bump) { + case "patch": + return `${major}.${minor}.${patch + 1}`; + case "minor": + return `${major}.${minor + 1}.0`; + case "major": + return `${major + 1}.0.0`; + default: + throw new Error(`Invalid bump type: ${bump}`); + } +})(); + +console.log(`New version: ${newVersion}`); + +pkg.version = newVersion; +await Bun.file("package.json").write(`${JSON.stringify(pkg, null, 2)}\n`); +console.log("Updated package.json"); + +console.log("Building project..."); +await $`bun run build`; +console.log("Build completed"); + +console.log("Authenticating with npm..."); +await $`npm config set //registry.npmjs.org/:_authToken ${process.env.NPM_TOKEN}`; + +console.log("Publishing to npm..."); +await $`npm publish --access public`; +console.log("Published to npm"); + +const output = `version=${newVersion}\ntag=v${newVersion}\n`; +if (process.env.GITHUB_OUTPUT) { + await Bun.write(process.env.GITHUB_OUTPUT, output); +} + +console.log(`Successfully published v${newVersion}!`); diff --git a/scripts/execute-tests.ts b/scripts/execute-tests.ts index d4ccad7..ebc92a9 100644 --- a/scripts/execute-tests.ts +++ b/scripts/execute-tests.ts @@ -81,8 +81,7 @@ async function main(): Promise { let testFiles: string[]; try { testFiles = await findTestFiles(opts.testsDir); - } catch (err) { - const _msg = err instanceof Error ? err.message : String(err); + } catch { process.exit(1); } diff --git a/src/index.ts b/src/index.ts index c877d6e..34824f1 100755 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { pushCommand } from "./commands/push"; import { statusCommand } from "./commands/status"; import { syncCommand } from "./commands/sync"; import { unsyncCommand } from "./commands/unsync"; +import { checkForUpdates } from "./utils/update-check"; // Read version from package.json const __filename = fileURLToPath(import.meta.url); @@ -33,6 +34,11 @@ const commands = { async function main() { const args = process.argv.slice(2); + await checkForUpdates({ + currentVersion: VERSION, + packageName: packageJson.name, + }); + // Handle --version flag if (args.includes("--version") || args.includes("-v")) { console.log(`syncode v${VERSION}`); diff --git a/src/utils/update-check.ts b/src/utils/update-check.ts new file mode 100644 index 0000000..ba446a8 --- /dev/null +++ b/src/utils/update-check.ts @@ -0,0 +1,101 @@ +import { clearTimeout, setTimeout } from "node:timers"; + +export interface UpdateCheckOptions { + currentVersion: string; + packageName: string; +} + +const UPDATE_TIMEOUT_MS = 2000; + +export function isNpxInvocation() { + const argv1 = process.argv[1] || ""; + const execPath = process.env.npm_execpath || ""; + const userAgent = process.env.npm_config_user_agent || ""; + const npxPathMatch = argv1.includes("/_npx/") || argv1.includes("\\_npx\\"); + const execMatch = execPath.includes("npx") || execPath.includes("npx-cli"); + const agentMatch = userAgent.includes("npx"); + return npxPathMatch || execMatch || agentMatch; +} + +export function parseVersion(version: string) { + const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function isOutdated(current: string, latest: string) { + const currentParts = parseVersion(current); + const latestParts = parseVersion(latest); + if (!currentParts || !latestParts) return false; + for (let i = 0; i < 3; i += 1) { + const currentValue = currentParts[i]; + const latestValue = latestParts[i]; + if (currentValue === undefined || latestValue === undefined) return false; + if (latestValue > currentValue) return true; + if (latestValue < currentValue) return false; + } + return false; +} + +export function formatUpdateNotice(params: { + current: string; + latest: string; + packageName: string; +}) { + const updateCommand = `npm install -g ${params.packageName}@latest`; + const lines = [ + "📦 A new version of Syncode is", + "available!", + "", + `Current: ${params.current}`, + `Latest: ${params.latest}`, + "", + "Run to update:", + updateCommand, + ]; + + const contentWidth = Math.max(...lines.map((line) => line.length)); + const top = `┌${"─".repeat(contentWidth + 2)}┐`; + const bottom = `└${"─".repeat(contentWidth + 2)}┘`; + const boxed = lines + .map((line) => `│ ${line.padEnd(contentWidth, " ")} │`) + .join("\n"); + return `${top}\n${boxed}\n${bottom}`; +} + +async function fetchLatestVersion(packageName: string) { + const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), UPDATE_TIMEOUT_MS); + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) return null; + const data = (await response.json()) as { version?: string }; + return typeof data.version === "string" ? data.version : null; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +export async function checkForUpdates({ + currentVersion, + packageName, +}: UpdateCheckOptions) { + if (isNpxInvocation()) return; + + const latest = await fetchLatestVersion(packageName); + if (!latest) return; + + if (isOutdated(currentVersion, latest)) { + console.log( + formatUpdateNotice({ + current: currentVersion, + latest, + packageName, + }), + ); + } +} diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts new file mode 100644 index 0000000..eb5d5f4 --- /dev/null +++ b/tests/update-check.test.ts @@ -0,0 +1,173 @@ +#!/usr/bin/env tsx + +import assert from "node:assert/strict"; +import * as updateCheck from "../src/utils/update-check"; + +type TestCase = { + name: string; + run: () => void | Promise; +}; + +const tests: TestCase[] = []; +const originalArgv1 = process.argv[1]; +const originalEnvExecPath = process.env.npm_execpath; +const originalEnvUserAgent = process.env.npm_config_user_agent; + +function test(name: string, run: TestCase["run"]) { + tests.push({ name, run }); +} + +function cleanup() { + process.argv[1] = originalArgv1; + process.env.npm_execpath = originalEnvExecPath ?? ""; + process.env.npm_config_user_agent = originalEnvUserAgent ?? ""; +} + +test("parseVersion handles valid versions", () => { + assert.deepStrictEqual(updateCheck.parseVersion("1.2.3"), [1, 2, 3]); + assert.deepStrictEqual(updateCheck.parseVersion("0.0.0"), [0, 0, 0]); + assert.deepStrictEqual(updateCheck.parseVersion("10.20.30"), [10, 20, 30]); +}); + +test("parseVersion handles invalid versions", () => { + assert.strictEqual(updateCheck.parseVersion("1.2"), null); + assert.strictEqual(updateCheck.parseVersion("1.2.3.4"), null); + assert.strictEqual(updateCheck.parseVersion("v1.2.3"), null); + assert.strictEqual(updateCheck.parseVersion(""), null); + assert.strictEqual(updateCheck.parseVersion("invalid"), null); +}); + +test("isOutdated detects newer versions", () => { + assert.strictEqual(updateCheck.isOutdated("1.0.0", "1.0.1"), true); + assert.strictEqual(updateCheck.isOutdated("1.0.0", "1.1.0"), true); + assert.strictEqual(updateCheck.isOutdated("1.0.0", "2.0.0"), true); + assert.strictEqual(updateCheck.isOutdated("1.2.3", "1.2.4"), true); +}); + +test("isOutdated returns false when up to date", () => { + assert.strictEqual(updateCheck.isOutdated("1.0.0", "1.0.0"), false); + assert.strictEqual(updateCheck.isOutdated("1.1.0", "1.0.0"), false); + assert.strictEqual(updateCheck.isOutdated("1.0.1", "1.0.0"), false); + assert.strictEqual(updateCheck.isOutdated("1.2.4", "1.2.3"), false); + assert.strictEqual(updateCheck.isOutdated("2.0.0", "1.0.0"), false); +}); + +test("isOutdated handles invalid versions gracefully", () => { + assert.strictEqual(updateCheck.isOutdated("invalid", "1.0.0"), false); + assert.strictEqual(updateCheck.isOutdated("1.0.0", "invalid"), false); + assert.strictEqual(updateCheck.isOutdated("1.2", "1.2.4"), false); +}); + +test("formatUpdateNotice creates boxed output", () => { + const output = updateCheck.formatUpdateNotice({ + current: "1.0.0", + latest: "1.1.0", + packageName: "@donnes/syncode", + }); + + assert.ok(output.includes("┌")); + assert.ok(output.includes("┐")); + assert.ok(output.includes("└")); + assert.ok(output.includes("┘")); + assert.ok(output.includes("│")); + assert.ok(output.includes("─")); + assert.ok(output.includes("Current: 1.0.0")); + assert.ok(output.includes("Latest: 1.1.0")); + assert.ok(output.includes("npm install -g @donnes/syncode@latest")); +}); + +test("formatUpdateNotice handles different package names", () => { + const output = updateCheck.formatUpdateNotice({ + current: "1.0.0", + latest: "2.0.0", + packageName: "test-package", + }); + + assert.ok(output.includes("npm install -g test-package@latest")); +}); + +test("isNpxInvocation detects npx execution", () => { + const originalArgv1 = process.argv[1]; + try { + process.argv[1] = "/tmp/_npx/package/lib/index.js"; + assert.strictEqual(updateCheck.isNpxInvocation(), true); + + process.argv[1] = "C:\\Users\\_npx\\package\\lib\\index.js"; + assert.strictEqual(updateCheck.isNpxInvocation(), true); + } finally { + process.argv[1] = originalArgv1; + } +}); + +test("isNpxInvocation detects npx via npm_execpath", () => { + const originalEnvNodeExecpath = process.env.npm_execpath; + try { + process.env.npm_execpath = "/usr/local/lib/node_modules/npm/bin/npx-cli.js"; + assert.strictEqual(updateCheck.isNpxInvocation(), true); + + process.env.npm_execpath = + "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js"; + assert.strictEqual(updateCheck.isNpxInvocation(), true); + } finally { + process.env.npm_execpath = originalEnvNodeExecpath ?? ""; + } +}); + +test("isNpxInvocation detects npx via user agent", () => { + const originalEnvUserAgent = process.env.npm_config_user_agent; + try { + process.env.npm_config_user_agent = "npm/9.0.0 node/v18.0.0 npx/9.0.0"; + assert.strictEqual(updateCheck.isNpxInvocation(), true); + } finally { + process.env.npm_config_user_agent = originalEnvUserAgent ?? ""; + } +}); + +test("isNpxInvocation returns false for global install", () => { + const originalArgv1 = process.argv[1]; + const originalEnvNodeExecpath = process.env.npm_execpath; + const originalEnvUserAgent = process.env.npm_config_user_agent; + + try { + process.argv[1] = "/usr/local/bin/syncode"; + process.env.npm_execpath = ""; + process.env.npm_config_user_agent = ""; + + assert.strictEqual(updateCheck.isNpxInvocation(), false); + } finally { + process.argv[1] = originalArgv1; + process.env.npm_execpath = originalEnvNodeExecpath ?? ""; + process.env.npm_config_user_agent = originalEnvUserAgent ?? ""; + } +}); + +test("isOutdated comparison edge cases", () => { + assert.strictEqual(updateCheck.isOutdated("9.9.9", "10.0.0"), true); + assert.strictEqual(updateCheck.isOutdated("10.0.0", "9.9.9"), false); + assert.strictEqual(updateCheck.isOutdated("0.0.0", "0.0.1"), true); + assert.strictEqual(updateCheck.isOutdated("1.9.9", "2.0.0"), true); + assert.strictEqual(updateCheck.isOutdated("2.0.0", "2.0.1"), true); + assert.strictEqual(updateCheck.isOutdated("2.0.0", "2.1.0"), true); + assert.strictEqual(updateCheck.isOutdated("2.1.0", "3.0.0"), true); +}); + +async function run() { + for (const { name, run } of tests) { + try { + await run(); + console.log(`✓ ${name}`); + } catch (error) { + console.error(`✗ ${name}`); + console.error(error); + process.exitCode = 1; + } finally { + cleanup(); + } + } + + if (process.exitCode) { + process.exit(1); + } +} + +await run();