Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions script/publish.ts
Original file line number Diff line number Diff line change
@@ -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}!`);
3 changes: 1 addition & 2 deletions scripts/execute-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ async function main(): Promise<void> {
let testFiles: string[];
try {
testFiles = await findTestFiles(opts.testsDir);
} catch (err) {
const _msg = err instanceof Error ? err.message : String(err);
} catch {
process.exit(1);
}

Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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}`);
Expand Down
101 changes: 101 additions & 0 deletions src/utils/update-check.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
);
}
}
173 changes: 173 additions & 0 deletions tests/update-check.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
};

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();
Loading