summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornetop://ウィビ <paul@webb.page>2026-04-11 15:01:41 -0700
committernetop://ウィビ <paul@webb.page>2026-04-11 15:01:41 -0700
commit3f7f5dc1b5ca4ba6a9acaf101d2e52b64edd2705 (patch)
tree9c496baae8c825b548fbec43f198006856ee30c6
initial commitHEADprimary
-rw-r--r--.gitignore9
-rw-r--r--README.md404
-rw-r--r--banner.pngbin0 -> 213596 bytes
-rwxr-xr-xcli.ts422
-rw-r--r--deno.json15
-rw-r--r--justfile43
-rw-r--r--mod.ts269
-rw-r--r--test.ts368
-rw-r--r--version.txt1
9 files changed, 1531 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5af4d48
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+# OS artefacts
+.DS_Store
+Thumbs.db
+
+# directories
+build/
+
+# &c
+*.lock
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b5f8615
--- /dev/null
+++ b/README.md
@@ -0,0 +1,404 @@
+
+![](banner.png "ChronVer banner")
+
+**[ChronVer](https://chronver.org) (Chronological Versioning) is calendar‑based versioning.** In the age of rapid software releases, knowing _when_ something released is more important than arbitrary numbers from an outdated versioning system that most people never adhere to anyway. Finally, versioning for the rest of us.
+
+
+
+## Why ChronVer?
+
+```
+2025.07.21 ← You know exactly when this was released
+v3.2.1 ← You have no idea when this happened
+```
+
+Semantic versioning is great for large systems like libraries and computers. Most software would benefit from **time‑based versioning** that's immediately understandable to everyone on a team, not just the technical‑minded.
+
+
+
+## Format
+
+```
+YYYY.MM.DD[.CHANGESET][-FEATURE|-break]
+```
+
+### Examples
+
+| Version | Description |
+|------------------------|-------------------------------|
+| `2025.07.21` | Released July 21st, 2025 |
+| `2025.07.21.1` | First hotfix that day |
+| `2025.07.21.3` | Third change that day |
+| `2025.07.21-feature` | Feature branch for that date |
+| `2025.07.21.1-feature` | Feature branch with changeset |
+| `2025.07.21.1-break` | Breaking change |
+
+
+
+## Installation
+
+### Deno
+
+```sh
+# install
+deno add jsr:@chronver/chronver
+
+# import in your code
+import { ChronVer } from "jsr:@chronver/chronver";
+
+# install CLI globally
+# "resolver diagnostics" will appear when using this method but it's fine
+deno install --allow-read --allow-write --global --name chronver https://raw.githubusercontent.com/ChronVer/chronver/refs/heads/primary/cli.ts --import-map https://raw.githubusercontent.com/ChronVer/chronver/refs/heads/primary/deno.json
+```
+
+### npm/bun
+
+```sh
+# install via npm
+npx jsr add @chronver/chronver
+
+# install via bun
+bunx jsr add @chronver/chronver
+
+# import in your code
+import { ChronVer } from "@chronver/chronver";
+```
+
+ChronVer is especially powerful with [husky](https://typicode.github.io/husky/). Here's how I use it in my Node projects (using [bun](https://bun.sh)):
+
+```js
+// package.json
+ "devDependencies": {
+ "del-cli": "^6.0.0",
+ "husky": "^9.1.7"
+ },
+ "scripts": {
+ "build": "bun run clean && vite build",
+ "clean": "del './build'",
+ "increment": "chronver increment",
+ "pre-commit": "bun run build && bun run increment && git add -A :/",
+ "prepare": "husky"
+ }
+```
+
+Now every time you push a commit, the `version` in `package.json` gets updated. Please note that you'll need to install the `chronver` CLI globally using the Deno method above or the path method below (recommended).
+
+### macOS/Linux
+
+You can find releases of the CLI on the [releases page](https://github.com/ChronVer/chronver/releases) of this repo.
+
+```sh
+# create personal bin directory if it doesn't exist
+mkdir -p ~/.local/bin
+
+# add to PATH in your shell profile (~/.bashrc, ~/.zshrc, etc.)
+echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
+source ~/.zshrc
+
+# move binary there
+mv chronver ~/.local/bin/
+```
+
+
+
+## Usage
+
+### Basic API
+
+```ts
+import { ChronVer } from "jsr:@chronver/chronver";
+
+// create new version with today's date
+const version = new ChronVer();
+console.log(version.toString()); // "2025.07.21"
+
+// parse existing version
+const parsed = new ChronVer("2024.04.03.1");
+console.log(parsed.year); // 2024
+console.log(parsed.month); // 4
+console.log(parsed.day); // 3
+console.log(parsed.changeset); // 1
+
+// compare versions
+const v1 = new ChronVer("2024.04.03");
+const v2 = new ChronVer("2024.04.04");
+console.log(v1.isOlderThan(v2)); // true
+console.log(v2.isNewerThan(v1)); // true
+
+// increment version
+const incremented = version.increment();
+console.log(incremented.toString()); // "2024.07.19.1" (if same day)
+```
+
+### Static Methods
+
+```ts
+// validation
+ChronVer.isValid("2024.04.03"); // true
+ChronVer.isValid("invalid"); // false
+
+// comparison
+ChronVer.compare("2024.04.03", "2024.04.04"); // -1
+
+// parsing
+const parsed = ChronVer.parseVersion("2024.04.03.1-feature");
+// {
+// changeset: 1,
+// date: "2024.04.03",
+// feature: "feature",
+// isBreaking: false,
+// version: "2024.04.03.1-feature"
+// }
+
+// sorting
+const versions = ["2024.04.05", "2024.04.03", "2024.04.04"];
+ChronVer.sort(versions); // ["2024.04.03", "2024.04.04", "2024.04.05"]
+
+// create from Date
+const date = new Date(2024, 3, 3); // April 3, 2024
+const version = ChronVer.fromDate(date, 5); // "2024.04.03.5"
+```
+
+### File Operations
+
+```ts
+// update package.json version
+const newVersion = await ChronVer.incrementInFile("package.json");
+console.log(newVersion); // "2025.07.21.1"
+
+// works with any JSON file
+await ChronVer.incrementInFile("deno.json");
+```
+
+
+
+## CLI Usage
+
+```bash
+# create new version
+chronver create # 2024.07.19
+
+# validate versions
+chronver validate "2024.04.03.1" # ✅ Valid: 2024.04.03.1
+
+# compare versions
+chronver compare "2024.04.03" "2024.04.04" # 2024.04.03 < 2024.04.04 (-1)
+
+# increment package.json
+chronver increment # 📦 Updated to: 2025.07.21.1
+chronver increment deno.json # 📦 Updated to: 2025.07.21.1
+
+# parse version details
+chronver parse "2024.04.03.1-feature"
+# 📋 Version: 2024.04.03.1-feature
+# 📅 Date: 2024.04.03
+# 🔢 Changeset: 1
+# 💥 Breaking: no
+# 🚀 Feature: feature
+# 📆 Day of week: Wednesday
+# ⏪ Released 107 days ago
+
+# sort versions
+chronver sort "2024.04.03" "2024.04.01" "2024.04.05"
+# 📊 Sorted (ascending):
+# 🔼 1. 2024.04.01
+# 🔼 2. 2024.04.03
+# 🔼 3. 2024.04.05
+
+chronver --sort-desc "2024.04.03" "2024.04.01" "2024.04.05"
+# 📊 Sorted (descending):
+# 🔽 1. 2024.04.05
+# 🔽 2. 2024.04.03
+# 🔽 3. 2024.04.01
+
+# create from specific date
+chronver format "2024-04-03" 5 # 2024.04.03.5
+
+# help
+chronver --help
+```
+
+
+
+## When to Use ChronVer
+
+### ✅ Perfect For
+
+- **SaaS platforms** with regular feature rollouts
+- **Mobile apps** with app store schedules
+- **Enterprise software** with quarterly releases
+- **Security tools** where timing matters
+- **Marketing‑driven releases** tied to campaigns
+- **Compliance software** with regulatory deadlines
+
+### ❌ Less Ideal For
+
+- **Libraries** consumed by other developers
+- **APIs** where breaking changes need clear signaling
+- **Projects** with irregular, feature‑driven releases
+- **Tools** where semantic compatibility matters more than timing
+
+
+
+## Comparison with SemVer
+
+| Aspect | ChronVer | SemVer |
+|-------------------|---------------------------------|---------------------------|
+| **Clarity** | Immediately shows when released | Requires lookup |
+| **Planning** | Aligns with calendar schedules | Feature‑driven |
+| **Communication** | "The April release" | "Version 3.2.1" |
+| **Sorting** | Chronological by default | Arbitrary without context |
+| **Compatibility** | Time‑based breaking changes | API contract based |
+| **Best for** | Time‑sensitive releases | Library compatibility |
+
+
+
+## Advanced Features
+
+### Feature Branches
+
+```ts
+const feature = new ChronVer("2024.04.03-new-ui");
+console.log(feature.feature); // "new-ui"
+console.log(feature.toString()); // "2024.04.03-new-ui"
+```
+
+### Breaking Changes
+
+```ts
+const breaking = new ChronVer("2024.04.03.1-break");
+console.log(breaking.isBreaking); // true
+```
+
+### Date Validation
+
+ChronVer validates actual calendar dates:
+
+```ts
+ChronVer.isValid("2024.02.29"); // true (2024 was a leap year)
+ChronVer.isValid("2023.02.29"); // false (2023 was not a leap year)
+ChronVer.isValid("2024.04.31"); // false (April has 30 days)
+```
+
+
+
+## Real-World Examples
+
+### `package.json` integration
+
+```json
+{
+ "name": "my-app",
+ "scripts": {
+ "version": "chronver increment"
+ },
+ "version": "2025.07.21.3"
+}
+```
+
+### CI/CD Pipeline
+
+```yaml
+# GitHub Actions example
+- name: Update version
+ run: |
+ chronver increment
+ git add package.json
+ git commit -m "chore: bump version to $(cat package.json | jq -r .version)"
+```
+
+### Release Notes
+
+```md
+## Release 2025.07.21 - Summer Feature Drop
+
+### New Features
+
+- Dark mode support
+- Mobile‑responsive dashboard
+- Advanced search filters
+
+### Bug Fixes
+
+- Fixed login timeout issue
+- Improved performance on large datasets
+
+### Breaking Changes
+
+None in this release.
+```
+
+
+
+## Development
+
+```sh
+# clone project
+git clone https://github.com/chronver/chronver.git && cd $_
+
+# lint
+deno check && deno lint
+
+# run tests
+deno test --allow-read --allow-write --fail-fast
+
+# run CLI locally
+deno run --allow-read --allow-write cli.ts --help
+```
+
+If you have [Just](https://just.systems/man/en/) installed:
+
+```sh
+just lint
+# deno check && deno lint
+
+just test
+# deno test --allow-read --allow-write --fail-fast
+
+just build
+# compile CLI to an executable
+```
+
+
+
+## Ports
+
+### Rust
+
+- https://github.com/dnaka91/chronver / https://crates.io/crates/chronver
+
+
+
+## License
+
+[Creative Commons ― CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
+
+
+
+## FAQ
+
+### Why not just use dates?
+
+ChronVer **is** dates, but with a structured format that supports multiple releases per day, feature branches, and breaking change indicators.
+
+### What about semantic compatibility?
+
+ChronVer can indicate breaking changes with the `-break` suffix. For situations where semantic versioning is **crucial**, stick with SemVer.
+
+### How do I migrate from SemVer?
+
+1. Choose your first ChronVer date (usually next release)
+2. Update your build tools to use `chronver increment`
+3. Update documentation to explain the new format
+4. Consider keeping a mapping in your `CHANGELOG`
+
+### Can I use both ChronVer and SemVer?
+
+Absolutely! Here's how your project could use ChronVer for releases and SemVer for API versions:
+
+```json
+{
+ "apiVersion": "v2.1.0",
+ "version": "2024.07.19.1"
+}
+```
diff --git a/banner.png b/banner.png
new file mode 100644
index 0000000..4cc1bf5
--- /dev/null
+++ b/banner.png
Binary files differ
diff --git a/cli.ts b/cli.ts
new file mode 100755
index 0000000..24011c5
--- /dev/null
+++ b/cli.ts
@@ -0,0 +1,422 @@
+#!/usr/bin/env -S deno run --allow-read --allow-write
+
+
+
+//// import
+
+import { parseArgs } from "@std/cli";
+
+//// util
+
+import { ChronVer } from "./mod.ts";
+
+const VERSION = await getVersion();
+
+interface CliArgs {
+ _: string[];
+ compare?: string[];
+ create?: boolean;
+ format?: string;
+ help?: boolean;
+ increment?: string;
+ parse?: string;
+ sort?: string[];
+ "sort-desc"?: boolean;
+ validate?: string;
+ version?: boolean;
+}
+
+
+
+//// program
+
+if (import.meta.main) {
+ try {
+ await main();
+ } catch(error) {
+ console.error(`❌ Unexpected error: ${(error as Error).message}`);
+ Deno.exit(1);
+ }
+}
+
+async function main(): Promise<void> {
+ const args = parseArgs(Deno.args, {
+ alias: { h: "help", v: "version" },
+ boolean: ["help", "version", "create", "sort-desc"],
+ collect: ["compare", "sort"],
+ string: ["validate", "increment", "parse", "format"]
+ }) as CliArgs;
+
+ if (args.help) {
+ showHelp();
+ return;
+ }
+
+ if (args.version) {
+ showVersion();
+ return;
+ }
+
+ if (args.validate) {
+ await validateVersion(args.validate);
+ return;
+ }
+
+ if (args.compare && args.compare.length >= 2) {
+ compareVersions(args.compare[0], args.compare[1]);
+ return;
+ }
+
+ if (args.increment !== undefined) {
+ await incrementVersion(args.increment || undefined);
+ return;
+ }
+
+ if (args.create) {
+ createVersion();
+ return;
+ }
+
+ if (args.parse) {
+ parseVersion(args.parse);
+ return;
+ }
+
+ if (args.sort && args.sort.length > 0) {
+ sortVersions(args.sort, false);
+ return;
+ }
+
+ if (args["sort-desc"]) {
+ const versions = args._.slice(0) as string[];
+
+ if (versions.length > 0) {
+ sortVersions(versions, args["sort-desc"]);
+ } else {
+ console.error("❌ Error: --sort-desc requires at least one version");
+ Deno.exit(1);
+ }
+
+ return;
+ }
+
+ if (args.format) {
+ const changeset = args._.length > 0 ? args._[0] as string : undefined;
+ formatFromDate(args.format, changeset);
+ return;
+ }
+
+ const command = args._[0] as string;
+
+ switch(command) {
+ case "compare": {
+ if (args._[1] && args._[2]) {
+ compareVersions(args._[1] as string, args._[2] as string);
+ } else {
+ console.error("❌ Error: compare command requires two version strings");
+ Deno.exit(1);
+ }
+
+ break;
+ }
+
+ case "create": {
+ createVersion();
+ break;
+ }
+
+ case "format": {
+ if (args._[1]) {
+ const changeset = args._[2] as string;
+ formatFromDate(args._[1] as string, changeset);
+ } else {
+ console.error("❌ Error: format command requires a date (YYYY-MM-DD)");
+ Deno.exit(1);
+ }
+
+ break;
+ }
+
+ case "increment": {
+ await incrementVersion(args._[1] as string);
+ break;
+ }
+
+ case "parse": {
+ if (args._[1]) {
+ parseVersion(args._[1] as string);
+ } else {
+ console.error("❌ Error: parse command requires a version string");
+ Deno.exit(1);
+ }
+
+ break;
+ }
+
+ case "sort": {
+ if (args._.length > 1) {
+ const versions = args._.slice(1) as string[];
+ sortVersions(versions, false);
+ } else {
+ console.error("❌ Error: sort command requires at least one version");
+ Deno.exit(1);
+ }
+
+ break;
+ }
+
+ case "validate": {
+ if (args._[1]) {
+ await validateVersion(args._[1] as string);
+ } else {
+ console.error("❌ Error: validate command requires a version string");
+ Deno.exit(1);
+ }
+
+ break;
+ }
+
+ default: {
+ if (command) {
+ console.error(`❌ Unknown command: ${command}`);
+ console.log("Run 'chronver --help' for usage information");
+ Deno.exit(1);
+ } else {
+ console.log("ChronVer CLI - Versioning for the rest of us");
+ console.log(`Run "chronver --help" for usage information`);
+ console.log(`Run "chronver create" to generate a new version`);
+ }
+ }
+ }
+}
+
+
+
+//// helper
+
+function compareVersions(v1: string, v2: string): void {
+ try {
+ const result = ChronVer.compare(v1, v2);
+ const symbol = result === -1 ?
+ "<" :
+ result === 1 ?
+ ">" :
+ "=";
+
+ console.log(`${v1} ${symbol} ${v2} (${result})`);
+
+ if (result === -1)
+ console.log(`${v1} is older than ${v2}`);
+ else if (result === 1)
+ console.log(`${v1} is newer than ${v2}`);
+ else
+ console.log("Versions are equal");
+ } catch(error) {
+ console.error(`❌ Error: ${(error as Error).message}`);
+ Deno.exit(1);
+ }
+}
+
+function createVersion(): void {
+ try {
+ const version = new ChronVer();
+ console.log(version.toString());
+ } catch (error) {
+ console.error(`❌ Error: ${(error as Error).message}`);
+ Deno.exit(1);
+ }
+}
+
+function formatFromDate(dateStr: string, changeset?: string): void {
+ try {
+ const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+
+ if (!match)
+ throw new Error("Date must be in YYYY-MM-DD format");
+
+ const [, year, month, day] = match;
+ const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
+ const changesetNum = changeset ? parseInt(changeset) : 0;
+
+ if (isNaN(changesetNum) || changesetNum < 0)
+ throw new Error("Changeset must be a non-negative number");
+
+ const version = ChronVer.fromDate(date, changesetNum);
+ console.log(version.toString());
+ } catch(error) {
+ console.error(`❌ Error: ${(error as Error).message}`);
+ Deno.exit(1);
+ }
+}
+
+async function getVersion() {
+ let version = "";
+
+ try {
+ version = await Deno.readTextFile("./version.txt");
+ } catch {
+ /*** ignore ***/
+ }
+
+ return version.trim();
+}
+
+async function incrementVersion(filename?: string): Promise<void> {
+ try {
+ const file = filename || "package.json";
+ console.log(`📦 Incrementing version in ${file}...`);
+
+ const newVersion = await ChronVer.incrementInFile(file);
+ console.log(`✅ Updated to: ${newVersion}`);
+ } catch(error) {
+ if ((error as Error).message.includes("No such file")) {
+ console.error(`❌ File not found: ${filename || "package.json"}`);
+ console.log("💡 Create the file first or specify a different file");
+ } else {
+ console.error(`❌ Error: ${(error as Error).message}`);
+ }
+
+ Deno.exit(1);
+ }
+}
+
+function parseVersion(version: string): void {
+ try {
+ const parsed = ChronVer.parseVersion(version);
+
+ if (!parsed) {
+ console.log(`❌ Invalid version: ${version}`);
+ Deno.exit(1);
+ }
+
+ console.log(`📋 Version: ${parsed.version}`);
+ console.log(`📅 Date: ${parsed.date}`);
+ console.log(`🔢 Changeset: ${parsed.changeset}`);
+ console.log(`💥 Breaking: ${parsed.isBreaking ? "yes" : "no"}`);
+
+ if (parsed.feature)
+ console.log(`🚀 Feature: ${parsed.feature}`);
+
+ const chronVer = new ChronVer(version);
+ const date = new Date(chronVer.year, chronVer.month - 1, chronVer.day);
+ const dayOfWeek = date.toLocaleDateString("en-US", { weekday: "long" });
+ console.log(`📆 Day of week: ${dayOfWeek}`);
+
+ const now = new Date();
+ const diffTime = Math.abs(now.getTime() - date.getTime());
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+
+ if (date > now) {
+ if (diffDays === 1)
+ console.log("⏭️ Future release (in 1 day)");
+ else
+ console.log(`⏭️ Future release (in ${diffDays} days)`);
+ } else if (diffDays === 0) {
+ console.log(`🎯 Released today`);
+ } else {
+ if (diffDays === 1)
+ console.log("⏪ Released 1 day ago");
+ else
+ console.log(`⏪ Released ${diffDays} days ago`);
+ }
+ } catch(error) {
+ console.error(`❌ Error: ${(error as Error).message}`);
+ Deno.exit(1);
+ }
+}
+
+function showHelp(): void {
+ console.log(`
+ __
+ ____/ / _______ _____ __________
+/ __/ _ \\/ __/ _ \\/ _ | |/ / -_/ __/
+\\__/_//_/_/ \\___/_//_|___/\\__/_/
+
+ChronVer CLI ${VERSION}
+Versioning for the rest of us
+
+USAGE:
+ chronver [OPTIONS] [COMMAND]
+
+COMMANDS:
+ compare <v1> <v2> Compare two versions (-1, 0, 1)
+ create Create a new version with today's date
+ format <date> [changeset] Create version from date (YYYY-MM-DD format)
+ increment [file] Increment version in package.json (or specified file)
+ parse <version> Parse and display version components
+ sort <versions...> Sort versions in ascending order
+ validate <version> Check if a version string is valid
+
+OPTIONS:
+ -h, --help Show this help message
+ -v, --version Show CLI version
+ --sort-desc Sort versions in descending order
+
+EXAMPLES:
+ $ chronver compare "2024.04.03" "2024.04.04"
+ $ chronver create
+ $ chronver format "2024-04-03" 5
+ $ chronver increment
+ $ chronver increment deno.json
+ $ chronver parse "2024.04.03.1-feature"
+ $ chronver sort "2024.04.03" "2024.04.01" "2024.04.05"
+ $ chronver --sort-desc "2024.04.03" "2024.04.01" "2024.04.05"
+ $ chronver validate "2024.04.03.1"
+
+MORE INFO:
+ ChronVer is calendar-based versioning: YYYY.MM.DD[.CHANGESET][-FEATURE|-break]
+
+ Docs: https://chronver.org
+ Repo: https://github.com/chronver/chronver
+ `);
+}
+
+function showVersion(): void {
+ console.log(`chronver ${VERSION}`);
+}
+
+function sortVersions(versions: string[], descending = false): void {
+ try {
+ const sorted = ChronVer.sort(versions, descending);
+ const direction = descending ? "descending" : "ascending";
+
+ console.log(`📊 Sorted (${direction}):`);
+
+ console.log(versions);
+
+ sorted.forEach((version, index) => {
+ const prefix = descending ? "🔽" : "🔼";
+ console.log(`${prefix} ${index + 1}. ${version}`);
+ });
+ } catch(error) {
+ console.error(`❌ Error: ${(error as Error).message}`);
+ Deno.exit(1);
+ }
+}
+
+function validateVersion(version: string): void {
+ try {
+ const isValid = ChronVer.isValid(version);
+
+ if (isValid) {
+ console.log(`✅ Valid: ${version}`);
+ const parsed = new ChronVer(version);
+ console.log(` Components: ${parsed.year}-${parsed.month.toString().padStart(2, "0")}-${parsed.day.toString().padStart(2, "0")}`);
+
+ if (parsed.changeset > 0)
+ console.log(` Changeset: ${parsed.changeset}`);
+
+ if (parsed.feature)
+ console.log(` Feature: ${parsed.feature}`);
+
+ if (parsed.isBreaking)
+ console.log(` Breaking: yes`);
+ } else {
+ console.log(`❌ Invalid: ${version}`);
+ Deno.exit(1);
+ }
+ } catch(error) {
+ console.error(`❌ Error: ${(error as Error).message}`);
+ Deno.exit(1);
+ }
+}
diff --git a/deno.json b/deno.json
new file mode 100644
index 0000000..dde5eb9
--- /dev/null
+++ b/deno.json
@@ -0,0 +1,15 @@
+{
+ "exports": "./mod.ts",
+ "imports": {
+ "@std/assert": "jsr:@std/assert@^1.0.13",
+ "@std/cli": "jsr:@std/cli@^1.0.20"
+ },
+ "license": "MIT",
+ "lock": false,
+ "name": "@chronver/chronver",
+ "permissions": {
+ "read": true,
+ "write": true
+ },
+ "version": "2.1.0"
+}
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..3d7d92a
--- /dev/null
+++ b/justfile
@@ -0,0 +1,43 @@
+default:
+ @just --list
+
+build: build-linux build-mac build-mac-intel build-windows
+
+build-linux:
+ @echo "[INFO] Building Linux executable…"
+ deno compile --allow-read --allow-write --output build/linux/chronver --target x86_64-unknown-linux-gnu cli.ts
+
+build-mac:
+ @echo "[INFO] Building macOS executable…"
+ deno compile --allow-read --allow-write --output build/mac/chronver --target aarch64-apple-darwin cli.ts
+
+build-mac-intel:
+ @echo "[INFO] Building macOS (Intel) executable…"
+ deno compile --allow-read --allow-write --output build/mac_intel/chronver --target x86_64-apple-darwin cli.ts
+
+build-windows:
+ @echo "[INFO] Building Windows executable…"
+ deno compile --allow-read --allow-write --output build/windows/chronver --target x86_64-pc-windows-msvc cli.ts
+
+clean:
+ rm -rf build
+
+lint:
+ deno check && deno lint
+
+test:
+ deno test --allow-read --allow-write --fail-fast
+
+dev:
+ deno run --allow-read --allow-write --watch mod.ts
+
+release: version clean build
+ @echo "[INFO] Release versioned and built"
+
+start:
+ deno run --allow-read --allow-write mod.ts
+
+version:
+ @echo "[INFO] Updating version.txt with ChronVer"
+ @deno eval "const now = new Date(); const version = \`\${now.getFullYear()}.\${String(now.getMonth() + 1).padStart(2, '0')}.\${String(now.getDate()).padStart(2, '0')}\`; console.log(version); await Deno.writeTextFile('version.txt', version);"
+ @echo "[INFO] Version updated: $(cat version.txt)"
diff --git a/mod.ts b/mod.ts
new file mode 100644
index 0000000..7d4e55c
--- /dev/null
+++ b/mod.ts
@@ -0,0 +1,269 @@
+
+
+
+//// export
+
+export class ChronVer {
+ private static readonly DATE_REGEX = /^(\d{4})\.(\d{2})\.(\d{2})$/;
+ private static readonly VERSION_REGEX = /^(\d{4})\.(?:0[1-9]|1[0-2])\.(?:0[1-9]|[12]\d|3[01])(?:\.(\d+))?(?:-(break|[a-zA-Z0-9-]+)(?:\.(\d+))?)?$/;
+
+ readonly changeset: number; /*** changeset number (0 if not specified) ***/
+ readonly day: number; /*** day component (1-31) ***/
+ readonly feature?: string; /*** feature name (if specified) ***/
+ readonly isBreaking: boolean; /*** whether this is a breaking change ***/
+ readonly month: number; /*** month component (1-12) ***/
+ readonly year: number; /*** year component ***/
+
+ constructor(version?: string) {
+ if (!version) {
+ const now = new Date();
+
+ this.changeset = 0;
+ this.day = now.getDate();
+ this.isBreaking = false;
+ this.month = now.getMonth() + 1;
+ this.year = now.getFullYear();
+
+ return;
+ }
+
+ const parsed = this.parseVersionString(version);
+
+ this.changeset = parsed.changeset;
+ this.day = parsed.day;
+ this.feature = parsed.feature;
+ this.isBreaking = parsed.isBreaking;
+ this.month = parsed.month;
+ this.year = parsed.year;
+
+ this.validate();
+ }
+
+ //// private methods
+
+ private getDaysInMonth(year: number, month: number): number {
+ return new Date(year, month, 0).getDate();
+ }
+
+ private parseVersionString(version: string): { changeset: number; day: number; feature?: string; isBreaking: boolean; month: number; year: number; } {
+ const match = version.match(ChronVer.VERSION_REGEX);
+
+ if (!match)
+ throw new ChronVerError(`Invalid ChronVer format: "${version}"`);
+
+ const [, yearStr, changesetStr, label, featureChangeset] = match;
+ const parts = version.split(".");
+
+ if (parts.length < 3)
+ throw new ChronVerError(`Invalid ChronVer format: "${version}"`);
+
+ const year = parseInt(yearStr, 10);
+ const month = parseInt(parts[1], 10);
+ const day = parseInt(parts[2], 10);
+ const isBreaking = label === "break";
+
+ let changeset = changesetStr ?
+ parseInt(changesetStr, 10) :
+ 0;
+
+ let feature: string | undefined;
+
+ if (label && !isBreaking) {
+ feature = label;
+
+ if (!changeset) {
+ changeset = featureChangeset ?
+ parseInt(featureChangeset, 10) :
+ 0;
+ }
+ }
+
+ return {
+ changeset,
+ day,
+ feature,
+ isBreaking,
+ month,
+ year
+ };
+ }
+
+ private validate(): void {
+ if (this.year < 1)
+ throw new ChronVerError("Year must be positive");
+
+ if (this.month < 1 || this.month > 12)
+ throw new ChronVerError("Month must be between 1 and 12");
+
+ const maxDays = this.getDaysInMonth(this.year, this.month);
+
+ if (this.day < 1 || this.day > maxDays)
+ throw new ChronVerError(`Day must be between 1 and ${maxDays} for ${this.year}-${this.month.toString().padStart(2, "0")}`);
+
+ if (this.changeset < 0)
+ throw new ChronVerError("Changeset cannot be negative");
+
+ if (this.feature && !/^[a-zA-Z0-9-]+$/.test(this.feature))
+ throw new ChronVerError("Feature name must contain only alphanumeric characters and hyphens");
+ }
+
+ //// public methods
+
+ compare(other: ChronVer): number {
+ /*** compare date components ***/
+ const comparisons = [
+ this.year - other.year,
+ this.month - other.month,
+ this.day - other.day,
+ this.changeset - other.changeset
+ ];
+
+ for (const diff of comparisons) {
+ if (diff !== 0)
+ return Math.sign(diff);
+ }
+
+ if (this.isBreaking !== other.isBreaking)
+ return this.isBreaking ? 1 : -1; /*** compare breaking changes ***/
+
+ /*** at this point, changeset is zero ***/
+ return 0;
+ }
+
+ equals(other: ChronVer): boolean {
+ return this.compare(other) === 0;
+ }
+
+ getDateString(): string {
+ return `${this.year}.${this.month.toString().padStart(2, "0")}.${this.day.toString().padStart(2, "0")}`;
+ }
+
+ increment(): ChronVer {
+ const today = new Date();
+
+ const isToday = this.year === today.getFullYear() &&
+ this.month === (today.getMonth() + 1) &&
+ this.day === today.getDate();
+
+ if (isToday) /*** already ran today, increment changeset ***/
+ return new ChronVer(`${this.year}.${this.month.toString().padStart(2, "0")}.${this.day.toString().padStart(2, "0")}.${this.changeset + 1}`);
+
+ return new ChronVer();
+ }
+
+ isNewerThan(other: ChronVer): boolean {
+ return this.compare(other) > 0;
+ }
+
+ isOlderThan(other: ChronVer): boolean {
+ return this.compare(other) < 0;
+ }
+
+ toString(): string {
+ const base = `${this.year}.${this.month.toString().padStart(2, "0")}.${this.day.toString().padStart(2, "0")}`;
+ let result = base;
+
+ if (this.changeset > 0)
+ result += `.${this.changeset}`;
+
+ if (this.feature)
+ result += `-${this.feature}`;
+
+ if (this.isBreaking)
+ result += "-break";
+
+ return result;
+ }
+
+ //// static methods
+
+ static compare(v1: string, v2: string): number {
+ try {
+ const version1 = new ChronVer(v1);
+ const version2 = new ChronVer(v2);
+
+ return version1.compare(version2);
+ } catch(error) {
+ throw new ChronVerError(`Failed to compare versions: ${(error as Error).message}`);
+ }
+ }
+
+ static fromDate(date: Date, changeset: number = 0): ChronVer {
+ const dateStr = `${date.getFullYear()}.${(date.getMonth() + 1).toString().padStart(2, "0")}.${date.getDate().toString().padStart(2, "0")}`;
+ return new ChronVer(changeset > 0 ? `${dateStr}.${changeset}` : dateStr);
+ }
+
+ static async incrementInFile(filename: string = "package.json"): Promise<string> {
+ try {
+ const content = await Deno.readTextFile(filename);
+ const json = JSON.parse(content);
+
+ if (!json.version) {
+ json.version = new ChronVer().toString();
+ } else {
+ const currentVersion = new ChronVer(json.version);
+ json.version = currentVersion.increment().toString();
+ }
+
+ await Deno.writeTextFile(filename, JSON.stringify(json, null, 2) + "\n");
+ return json.version;
+ } catch(error) {
+ if ( /*** not found errors ***/
+ error instanceof Deno.errors.NotFound ||
+ (error as Error).message.includes("No such file") ||
+ (error as Error).message.includes("ENOENT") ||
+ (error as Error).message.includes("not found"))
+ throw new ChronVerError(`File not found: ${filename}`);
+
+ if (error instanceof SyntaxError) /*** json parsing errors ***/
+ throw new ChronVerError(`Invalid JSON in ${filename}: ${(error as Error).message}`);
+
+ if ((error as Error).message.includes("permission") || (error as Error).message.includes("EACCES"))
+ throw new ChronVerError(`Permission denied accessing ${filename}`); /*** permission errors ***/
+
+ throw new ChronVerError(`Failed to update ${filename}: ${(error as Error).message}`);
+ }
+ }
+
+ static isValid(version: string): boolean {
+ try {
+ new ChronVer(version);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ static parseVersion(version: string): { changeset: number; date: string; feature?: string; isBreaking: boolean; version: string; } | null {
+ if (!this.isValid(version))
+ return null;
+
+ try {
+ const chronVer = new ChronVer(version);
+
+ return {
+ changeset: chronVer.changeset,
+ date: chronVer.getDateString(),
+ feature: chronVer.feature,
+ isBreaking: chronVer.isBreaking,
+ version
+ };
+ } catch {
+ return null;
+ }
+ }
+
+ static sort(versions: string[], descending: boolean = false): string[] {
+ return versions.slice().sort((a, b) => {
+ const result = ChronVer.compare(a, b);
+ return descending ? -result : result;
+ });
+ }
+}
+
+export class ChronVerError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ChronVerError";
+ }
+}
diff --git a/test.ts b/test.ts
new file mode 100644
index 0000000..c149137
--- /dev/null
+++ b/test.ts
@@ -0,0 +1,368 @@
+
+
+
+//// import
+
+import {
+ assert,
+ assertEquals,
+ assertFalse,
+ assertThrows
+} from "@std/assert";
+
+//// util
+
+import { ChronVer, ChronVerError } from "./mod.ts";
+
+const validVersions = [
+ "2024.04.03",
+ "2024.04.03.1",
+ "2024.04.03.15",
+ "2024.12.31.999",
+ "2024.04.03-feature",
+ "2024.04.03.1-feature",
+ "2024.04.03-break",
+ "2024.04.03.1-break",
+ "2024.04.03-feature-name",
+ "2024.04.03-feature.5"
+];
+
+const invalidVersions = [
+ "invalid",
+ "2024",
+ "2024.4.3",
+ "2024.04.32",
+ "2024.13.15",
+ "2024.00.15",
+ "2024.04.00",
+ "2024.04.03.-1",
+ "2024.04.03.1.2",
+ "24.04.03",
+ "2024.4.03",
+ "2024.04.3"
+];
+
+
+
+//// program
+
+Deno.test("ChronVer constructor", async(test) => {
+ await test.step("should create version with current date when no input", () => {
+ const version = new ChronVer();
+ const now = new Date();
+
+ assertEquals(version.year, now.getFullYear());
+ assertEquals(version.month, now.getMonth() + 1);
+ assertEquals(version.day, now.getDate());
+ assertEquals(version.changeset, 0);
+ assertEquals(version.isBreaking, false);
+ });
+
+ await test.step("should parse valid version strings correctly", () => {
+ const version = new ChronVer("2024.04.03.5");
+
+ assertEquals(version.year, 2024);
+ assertEquals(version.month, 4);
+ assertEquals(version.day, 3);
+ assertEquals(version.changeset, 5);
+ assertEquals(version.isBreaking, false);
+ });
+
+ await test.step("should parse feature versions", () => {
+ const version = new ChronVer("2024.04.03-feature");
+
+ assertEquals(version.feature, "feature");
+ assertEquals(version.changeset, 0);
+ assertEquals(version.isBreaking, false);
+ });
+
+ await test.step("should parse feature versions with changeset", () => {
+ const version = new ChronVer("2024.04.03-feature.5");
+
+ assertEquals(version.feature, "feature");
+ assertEquals(version.changeset, 5);
+ assertEquals(version.isBreaking, false);
+ });
+
+ await test.step("should parse breaking versions", () => {
+ const version = new ChronVer("2024.04.03.1-break");
+
+ assertEquals(version.isBreaking, true);
+ assertEquals(version.changeset, 1);
+ });
+
+ await test.step("should throw on invalid formats", () => {
+ for (const invalid of invalidVersions) {
+ assertThrows(() => new ChronVer(invalid), ChronVerError);
+ }
+ });
+});
+
+Deno.test("ChronVer validation", async(test) => {
+ await test.step("should validate leap years correctly", () => {
+ const leapYear = new ChronVer("2024.02.29");
+
+ assertEquals(leapYear.day, 29);
+ assertThrows(() => new ChronVer("2023.02.29"), ChronVerError);
+ });
+
+ await test.step("should validate month boundaries", () => {
+ assertThrows(() => new ChronVer("2024.13.01"), ChronVerError);
+ assertThrows(() => new ChronVer("2024.00.01"), ChronVerError);
+ });
+
+ await test.step("should validate day boundaries for different months", () => {
+ assertThrows(() => new ChronVer("2024.02.30"), ChronVerError);
+ assertThrows(() => new ChronVer("2024.04.31"), ChronVerError);
+ assertThrows(() => new ChronVer("2024.01.32"), ChronVerError);
+ });
+
+ await test.step("should validate negative changesets", () => {
+ assertThrows(() => new ChronVer("2024.04.03.-1"), ChronVerError);
+ });
+});
+
+Deno.test("ChronVer comparison", async(test) => {
+ await test.step("should compare dates correctly", () => {
+ const v1 = new ChronVer("2024.04.03");
+ const v2 = new ChronVer("2024.04.04");
+ const v3 = new ChronVer("2024.05.03");
+ const v4 = new ChronVer("2025.04.03");
+
+ assertEquals(v1.compare(v2), -1);
+ assertEquals(v2.compare(v1), 1);
+ assertEquals(v1.compare(v3), -1);
+ assertEquals(v1.compare(v4), -1);
+ assertEquals(v1.compare(v1), 0);
+ });
+
+ await test.step("should compare changesets correctly", () => {
+ const v1 = new ChronVer("2024.04.03.1");
+ const v2 = new ChronVer("2024.04.03.2");
+
+ assertEquals(v1.compare(v2), -1);
+ assertEquals(v2.compare(v1), 1);
+ });
+
+ await test.step("should handle breaking changes in comparison", () => {
+ const normal = new ChronVer("2024.04.03.1");
+ const breaking = new ChronVer("2024.04.03.1-break");
+
+ assertEquals(breaking.compare(normal), 1);
+ assertEquals(normal.compare(breaking), -1);
+ });
+
+ await test.step("should treat feature versions as equal when dates match", () => {
+ const v1 = new ChronVer("2024.04.03-feature1");
+ const v2 = new ChronVer("2024.04.03-feature2");
+
+ assertEquals(v1.compare(v2), 0);
+ });
+});
+
+Deno.test("ChronVer utility methods", async(test) => {
+ await test.step(`"isNewerThan" should work correctly`, () => {
+ const v1 = new ChronVer("2024.04.03");
+ const v2 = new ChronVer("2024.04.04");
+
+ assert(v2.isNewerThan(v1));
+ assertFalse(v1.isNewerThan(v2));
+ });
+
+ await test.step(`"isOlderThan" should work correctly`, () => {
+ const v1 = new ChronVer("2024.04.03");
+ const v2 = new ChronVer("2024.04.04");
+
+ assert(v1.isOlderThan(v2));
+ assertFalse(v2.isOlderThan(v1));
+ });
+
+ await test.step(`"equals" should work correctly`, () => {
+ const v1 = new ChronVer("2024.04.03.1");
+ const v2 = new ChronVer("2024.04.03.1");
+ const v3 = new ChronVer("2024.04.03.2");
+
+ assert(v1.equals(v2));
+ assertFalse(v1.equals(v3));
+ });
+
+ await test.step(`"getDateString" should return correct format`, () => {
+ const version = new ChronVer("2024.04.03.5");
+ assertEquals(version.getDateString(), "2024.04.03");
+ });
+});
+
+Deno.test("ChronVer string output", async(test) => {
+ await test.step("should format basic versions correctly", () => {
+ assertEquals(new ChronVer("2024.04.03").toString(), "2024.04.03");
+ assertEquals(new ChronVer("2024.04.03.1").toString(), "2024.04.03.1");
+ });
+
+ await test.step("should format feature versions correctly", () => {
+ assertEquals(new ChronVer("2024.04.03-feature").toString(), "2024.04.03-feature");
+ assertEquals(new ChronVer("2024.04.03-feature.5").toString(), "2024.04.03.5-feature");
+ });
+
+ await test.step("should format breaking versions correctly", () => {
+ assertEquals(new ChronVer("2024.04.03-break").toString(), "2024.04.03-break");
+ assertEquals(new ChronVer("2024.04.03.1-break").toString(), "2024.04.03.1-break");
+ });
+
+ await test.step("should pad months and days correctly", () => {
+ assertEquals(new ChronVer("2024.01.05").toString(), "2024.01.05");
+ assertEquals(new ChronVer("2024.12.25").toString(), "2024.12.25");
+ });
+});
+
+Deno.test("ChronVer increment", async(test) => {
+ await test.step("should increment changeset for same day", () => {
+ const today = new Date();
+ const todayStr = `${today.getFullYear()}.${(today.getMonth() + 1).toString().padStart(2, "0")}.${today.getDate().toString().padStart(2, "0")}`;
+ const version = new ChronVer(todayStr + ".5");
+ const incremented = version.increment();
+
+ assertEquals(incremented.toString(), todayStr + ".6");
+ });
+
+ await test.step("should create new version for different day", () => {
+ const version = new ChronVer("2023.01.01.5");
+ const incremented = version.increment();
+ const today = new Date();
+ const expectedDate = `${today.getFullYear()}.${(today.getMonth() + 1).toString().padStart(2, "0")}.${today.getDate().toString().padStart(2, "0")}`;
+
+ assertEquals(incremented.toString(), expectedDate);
+ });
+});
+
+Deno.test("ChronVer static methods", async(test) => {
+ await test.step(`"compare" should work with strings`, () => {
+ assertEquals(ChronVer.compare("2024.04.03", "2024.04.04"), -1);
+ assertEquals(ChronVer.compare("2024.04.04", "2024.04.03"), 1);
+ assertEquals(ChronVer.compare("2024.04.03", "2024.04.03"), 0);
+ });
+
+ await test.step(`"isValid" should validate correctly`, () => {
+ for (const valid of validVersions) {
+ assert(ChronVer.isValid(valid));
+ }
+
+ for (const invalid of invalidVersions) {
+ assertFalse(ChronVer.isValid(invalid));
+ }
+ });
+
+ await test.step(`"parseVersion" should return correct structure`, () => {
+ const parsed = ChronVer.parseVersion("2024.04.03.5-feature");
+
+ assertEquals(parsed?.changeset, 5);
+ assertEquals(parsed?.date, "2024.04.03");
+ assertEquals(parsed?.version, "2024.04.03.5-feature");
+ assertEquals(parsed?.isBreaking, false);
+ assertEquals(parsed?.feature, "feature");
+ });
+
+ await test.step(`"parseVersion" should return null for invalid versions`, () => {
+ assertEquals(ChronVer.parseVersion("invalid"), null);
+ });
+
+ await test.step(`"fromDate" should create version from Date object`, () => {
+ const date = new Date(2024, 3, 3); /*** month is 0-indexed ***/
+ const version = ChronVer.fromDate(date, 5);
+
+ assertEquals(version.year, 2024);
+ assertEquals(version.month, 4);
+ assertEquals(version.day, 3);
+ assertEquals(version.changeset, 5);
+ });
+
+ await test.step(`"sort" should order versions correctly`, () => {
+ const versions = [
+ "2024.04.05",
+ "2024.04.03.1",
+ "2024.04.03",
+ "2024.04.04"
+ ];
+
+ const sorted = ChronVer.sort(versions);
+
+ assertEquals(sorted, [
+ "2024.04.03",
+ "2024.04.03.1",
+ "2024.04.04",
+ "2024.04.05"
+ ]);
+
+ const sortedDesc = ChronVer.sort(versions, true);
+
+ assertEquals(sortedDesc, [
+ "2024.04.05",
+ "2024.04.04",
+ "2024.04.03.1",
+ "2024.04.03"
+ ]);
+ });
+});
+
+Deno.test("ChronVer file operations", async(test) => {
+ const testFile = "test-package.json";
+
+ await test.step(`"incrementInFile" should create version if missing`, async() => {
+ const testData = { name: "test-package" };
+ await Deno.writeTextFile(testFile, JSON.stringify(testData));
+
+ const newVersion = await ChronVer.incrementInFile(testFile);
+ assert(ChronVer.isValid(newVersion));
+
+ const content = await Deno.readTextFile(testFile);
+ const json = JSON.parse(content);
+ assertEquals(json.version, newVersion);
+
+ await Deno.remove(testFile);
+ });
+
+ await test.step(`"incrementInFile" should increment existing version`, async() => {
+ const today = new Date();
+ const todayStr = `${today.getFullYear()}.${(today.getMonth() + 1).toString().padStart(2, "0")}.${today.getDate().toString().padStart(2, "0")}`;
+ const testData = { name: "test-package", version: `${todayStr}.1` };
+
+ await Deno.writeTextFile(testFile, JSON.stringify(testData));
+
+ const newVersion = await ChronVer.incrementInFile(testFile);
+ assertEquals(newVersion, `${todayStr}.2`);
+
+ await Deno.remove(testFile);
+ });
+
+ await test.step(`"incrementInFile" should handle file not found`, async() => {
+ try {
+ await ChronVer.incrementInFile("nonexistent.json");
+ assert(false, "Expected ChronVerError to be thrown");
+ } catch(error) {
+ assert(error instanceof ChronVerError, "Should throw ChronVerError");
+ assert(error.message.includes("File not found"), "Should mention file not found");
+ }
+ });
+});
+
+Deno.test("ChronVer edge cases", async(test) => {
+ await test.step("should handle year boundaries", () => {
+ const version = new ChronVer("0001.01.01");
+
+ assertEquals(version.year, 1);
+ assertThrows(() => new ChronVer("0000.01.01"), ChronVerError);
+ });
+
+ await test.step("should handle complex feature names", () => {
+ const version = new ChronVer("2024.04.03-feature-name-123");
+ assertEquals(version.feature, "feature-name-123");
+ });
+
+ await test.step("should handle large changesets", () => {
+ const version = new ChronVer("2024.04.03.999999");
+ assertEquals(version.changeset, 999999);
+ });
+
+ await test.step(`"compare" should throw on invalid versions`, () => {
+ assertThrows(() => ChronVer.compare("invalid", "2024.04.03"), ChronVerError);
+ });
+});
diff --git a/version.txt b/version.txt
new file mode 100644
index 0000000..9a03c90
--- /dev/null
+++ b/version.txt
@@ -0,0 +1 @@
+2025.07.24 \ No newline at end of file