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
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"db:migrate:remote": "wrangler d1 migrations apply gittensory --remote",
"drizzle:generate": "drizzle-kit generate",
"build:mcp": "npm --workspace @jsonbored/gittensory-mcp run build",
"build:miner": "npm --workspace @jsonbored/gittensory-engine run build && npm --workspace @jsonbored/gittensory-miner run build",
"test:mcp-pack": "node scripts/check-mcp-package.mjs",
"rees:install": "npm ci --prefix review-enrichment --prefer-offline --no-audit --no-fund",
"rees:test": "npm run rees:install && npm --prefix review-enrichment test",
Expand Down Expand Up @@ -60,7 +61,7 @@
"test:smoke:observability": "node scripts/smoke-observability-traces.mjs",
"test:smoke:browser:install": "playwright install chromium",
"test:smoke:browser": "node scripts/smoke-ui-browser.mjs",
"test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run rees:test && npm run ui:openapi:check && npm run ui:version-audit && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run build:miner && npm run rees:test && npm run ui:openapi:check && npm run ui:version-audit && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:release": "npm run test:ci && npm run changelog:check",
"test:release:mcp": "npm run test:ci && npm run changelog:check:mcp",
"test:watch": "vitest",
Expand Down
38 changes: 38 additions & 0 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# @jsonbored/gittensory-miner

Foundation CLI for the local Gittensory miner runtime.

This package is the future home of the autonomous discover → analyze → plan → prepare → create → manage miner workflow. In this foundation phase it provides the package scaffold, a minimal CLI surface for `--help` and `--version`, and a non-blocking npm registry version nudge on startup.

## Status

Current scope is intentionally small:

- workspace package wiring
- CLI entry point
- `--help` and `version` commands
- startup npm version nudge (override with `--no-update-check` or `GITTENSORY_MINER_NO_UPDATE_CHECK=1`)

Real miner commands land in follow-up issues.

## Install

From a local checkout:

```sh
npm install
npm --workspace @jsonbored/gittensory-miner run build
```

## Commands

```sh
gittensory-miner --help
gittensory-miner help
gittensory-miner --version
gittensory-miner version
```

## Version check

On every invocation the CLI starts an async npm registry lookup (5s timeout). When the installed package is behind `@jsonbored/gittensory-miner@latest`, it prints a one-line upgrade command to stderr without blocking or failing the requested command. Set `GITTENSORY_NPM_REGISTRY_URL` to point at a mirror, same as `@jsonbored/gittensory-mcp`.
46 changes: 46 additions & 0 deletions packages/gittensory-miner/bin/gittensory-miner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import { printHelp, printVersion, runCli } from "../lib/cli.js";
import {
awaitOpportunisticUpdateCheck,
resolveUpgradeCommand,
startUpdateCheck,
} from "../lib/update-check.js";

const cliArgs = process.argv.slice(2);
const require = createRequire(import.meta.url);
const packageName = "@jsonbored/gittensory-miner";
const packageVersion = require("../package.json").version;
const upgradeCommand = resolveUpgradeCommand(packageName);

const updateCheck = startUpdateCheck(cliArgs, {
packageName,
packageVersion,
upgradeCommand,
env: process.env,
});

if (
cliArgs.length === 0 ||
cliArgs.includes("--help") ||
cliArgs.includes("-h") ||
cliArgs[0] === "help"
) {
printHelp({ packageName });
await awaitOpportunisticUpdateCheck(updateCheck);
process.exit(0);
}

if (
cliArgs.includes("--version") ||
cliArgs.includes("-v") ||
cliArgs[0] === "version"
) {
printVersion({ packageName, packageVersion });
await awaitOpportunisticUpdateCheck(updateCheck);
process.exit(0);
}

const exitCode = runCli(cliArgs, { packageName });
await awaitOpportunisticUpdateCheck(updateCheck);
process.exit(exitCode);
3 changes: 3 additions & 0 deletions packages/gittensory-miner/lib/cli.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function printVersion(input: { packageName: string; packageVersion: string }): void;
export function printHelp(input: { packageName: string }): void;
export function runCli(cliArgs: string[], input: { packageName: string }): number;
28 changes: 28 additions & 0 deletions packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export function printVersion(input) {
console.log(`${input.packageName}/${input.packageVersion} (node ${process.version})`);
}

export function printHelp(input) {
console.log(
[
input.packageName,
"",
"Foundation CLI for the local Gittensory miner runtime.",
"",
"Usage:",
" gittensory-miner --help",
" gittensory-miner --version",
" gittensory-miner help",
" gittensory-miner version",
"",
"Options:",
" --no-update-check Skip the npm registry version nudge (also GITTENSORY_MINER_NO_UPDATE_CHECK=1)",
].join("\n"),
);
}

export function runCli(cliArgs, input) {
const command = cliArgs[0] ?? "";
console.error(`Unknown command: ${command}. Run ${input.packageName} --help.`);
return 1;
}
36 changes: 36 additions & 0 deletions packages/gittensory-miner/lib/update-check.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export function resolveNpmRegistryUrl(
env?: Record<string, string | undefined>,
): string;
export function resolveUpgradeCommand(packageName?: string): string;
export function shouldSkipUpdateCheck(
cliArgs: string[],
env?: Record<string, string | undefined>,
): boolean;
export function compareSemver(a: string, b: string): -1 | 0 | 1 | null;
export function fetchLatestPackageVersion(input: {
packageName: string;
npmRegistryUrl: string;
timeoutMs?: number;
}): Promise<string>;
export function maybePrintUpdateNudge(input: {
packageName: string;
packageVersion: string;
npmRegistryUrl: string;
upgradeCommand: string;
timeoutMs?: number;
}): Promise<void>;
export function startUpdateCheck(
cliArgs: string[],
input: {
packageName: string;
packageVersion: string;
upgradeCommand?: string;
env?: Record<string, string | undefined>;
timeoutMs?: number;
},
): Promise<void>;
export const updateCheckExitGraceMs: number;
export function awaitOpportunisticUpdateCheck(
updateCheck: Promise<void>,
graceMs?: number,
): Promise<void>;
155 changes: 155 additions & 0 deletions packages/gittensory-miner/lib/update-check.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
const defaultPackageName = "@jsonbored/gittensory-miner";
const defaultNpmRegistryUrl = "https://registry.npmjs.org";

function isLocalRegistryHost(hostname) {
const normalized = hostname.toLowerCase().replace(/\.$/, "");
return (
normalized === "localhost" ||
normalized === "127.0.0.1" ||
normalized === "::1" ||
normalized === "[::1]"
);
}

export function resolveNpmRegistryUrl(env = process.env) {
const raw = env.GITTENSORY_NPM_REGISTRY_URL?.trim();
if (!raw) return defaultNpmRegistryUrl;

let url;
try {
url = new URL(raw);
} catch {
return defaultNpmRegistryUrl;
}

if (url.username || url.password || url.search || url.hash || !url.hostname) {
return defaultNpmRegistryUrl;
}

const local = isLocalRegistryHost(url.hostname);
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
return defaultNpmRegistryUrl;
}

const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
return `${url.origin}${path}`;
}

export function resolveUpgradeCommand(packageName = defaultPackageName) {
return `npm install -g ${packageName}@latest`;
}

export function shouldSkipUpdateCheck(cliArgs, env = process.env) {
if (/^(1|true|yes)$/i.test(env.GITTENSORY_MINER_NO_UPDATE_CHECK ?? ""))
return true;
return cliArgs.includes("--no-update-check");
}

function parseSemver(version) {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(
String(version ?? "").trim(),
);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] ?? null,
};
}

function comparePrerelease(a, b) {
const left = a.split(".");
const right = b.split(".");
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
const leftId = left[index];
const rightId = right[index];
if (leftId === undefined) return -1;
if (rightId === undefined) return 1;
const leftNumeric = /^\d+$/.test(leftId);
const rightNumeric = /^\d+$/.test(rightId);
if (leftNumeric && rightNumeric) {
if (Number(leftId) !== Number(rightId))
return Number(leftId) < Number(rightId) ? -1 : 1;
} else if (leftNumeric !== rightNumeric) {
return leftNumeric ? -1 : 1;
} else if (leftId !== rightId) {
return leftId < rightId ? -1 : 1;
}
}
return 0;
}

export function compareSemver(a, b) {
const left = parseSemver(a);
const right = parseSemver(b);
if (!left || !right) return null;
for (const part of ["major", "minor", "patch"]) {
if (left[part] !== right[part]) return left[part] < right[part] ? -1 : 1;
}
if (left.prerelease === right.prerelease) return 0;
if (left.prerelease === null) return 1;
if (right.prerelease === null) return -1;
return comparePrerelease(left.prerelease, right.prerelease);
}

export async function fetchLatestPackageVersion(input) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), input.timeoutMs ?? 5000);
const registrySlug = input.packageName.startsWith("@")
? input.packageName.replace("/", "%2F")
: input.packageName;
const registryPath = `${input.npmRegistryUrl}/${registrySlug}/latest`;
try {
const response = await fetch(registryPath, {
signal: controller.signal,
headers: { accept: "application/json" },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || typeof payload.version !== "string")
throw new Error("npm_latest_version_unavailable");
return payload.version;
} finally {
clearTimeout(timeout);
}
}

// Non-blocking startup nudge: prints one upgrade line when local is behind npm latest.
// Mirrors packages/gittensory-mcp/bin/gittensory-mcp.js packageVersion/npmRegistryUrl/upgradeCommand (#2331).
export async function maybePrintUpdateNudge(input) {
try {
const latestVersion = await fetchLatestPackageVersion(input);
const comparison = compareSemver(input.packageVersion, latestVersion);
if (comparison !== null && comparison < 0) {
process.stderr.write(`${input.upgradeCommand}\n`);
}
} catch {
// Offline or unreachable registry — never block or fail the CLI.
}
}

export function startUpdateCheck(cliArgs, input) {
if (shouldSkipUpdateCheck(cliArgs, input.env)) return Promise.resolve();
return maybePrintUpdateNudge({
packageName: input.packageName,
packageVersion: input.packageVersion,
npmRegistryUrl: resolveNpmRegistryUrl(input.env),
upgradeCommand:
input.upgradeCommand ?? resolveUpgradeCommand(input.packageName),
timeoutMs: input.timeoutMs,
});
}

export const updateCheckExitGraceMs = 250;

// After command output is printed, give a fast registry response time to emit the nudge
// without waiting for the full lookup timeout on slow/offline registries.
export async function awaitOpportunisticUpdateCheck(
updateCheck,
graceMs = updateCheckExitGraceMs,
) {
await Promise.race([
updateCheck.catch(() => undefined),
new Promise((resolve) => setTimeout(resolve, graceMs)),
]);
}
Loading
Loading