shrinker-ai 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,6 +53,22 @@ The installer downloads these anonymous GitHub Release assets by default:
53
53
 
54
54
  Network allowlists need access to `raw.githubusercontent.com` for the installer script and `github.com/ivanduplenskikh/shrinker/releases/download/...` for release assets.
55
55
 
56
+ ### Update notices
57
+
58
+ Shrinker checks GitHub Releases at most once every 24 hours and prints an update notice to stderr when a newer version is available. The check only requests release metadata from GitHub; command output, paths, stats, and machine identifiers are not sent.
59
+
60
+ To disable update checks for one shell session:
61
+
62
+ ```powershell
63
+ $env:SHRINKER_UPDATE_CHECK = "0"
64
+ ```
65
+
66
+ Or set it in `~/.shrinker/config`:
67
+
68
+ ```text
69
+ SHRINKER_UPDATE_CHECK=0
70
+ ```
71
+
56
72
  ### Optional npm package install
57
73
 
58
74
  Use this only when npm registry access is available or preferred.
package/dist/src/cli.js CHANGED
@@ -9,6 +9,8 @@ import { formatMeasurements, measure } from "./metrics/measure.js";
9
9
  import { serveStatsDashboard, startStatsDashboard, writeStatsDashboard } from "./metrics/dashboard.js";
10
10
  import { classifyWrappedRun, commandSignature, isCoverageTrackingEnabled } from "./metrics/coverage.js";
11
11
  import { defaultStatsPath, formatCoverage, formatStats, formatStatsChart, getStats, recordRun, recordUncovered, } from "./metrics/stats-store.js";
12
+ import { checkForUpdate, formatUpdateNotice, markUpdateNoticeShown, wasUpdateNoticeShown } from "./updates/check.js";
13
+ import { getCurrentVersion } from "./version.js";
12
14
  function usage() {
13
15
  return `Usage:
14
16
  shrinker <command> [args...]
@@ -297,10 +299,25 @@ async function render(rawOutput, options, durationMs, exitCode) {
297
299
  }
298
300
  }
299
301
  }
302
+ async function maybeShowUpdateNotice() {
303
+ try {
304
+ const currentVersion = getCurrentVersion();
305
+ const result = await checkForUpdate(currentVersion ? { currentVersion } : {});
306
+ if (result.latestVersion && wasUpdateNoticeShown(result.latestVersion))
307
+ return;
308
+ const notice = formatUpdateNotice(result);
309
+ if (notice)
310
+ process.stderr.write(`${notice}\n`);
311
+ if (result.latestVersion && notice)
312
+ markUpdateNoticeShown(result.latestVersion);
313
+ }
314
+ catch { }
315
+ }
300
316
  async function main() {
301
317
  const options = parseArgs(process.argv.slice(2));
302
318
  if (options.mode === "help") {
303
319
  process.stdout.write(`${usage()}\n`);
320
+ await maybeShowUpdateNotice();
304
321
  return;
305
322
  }
306
323
  if (options.mode === "stats") {
@@ -321,6 +338,7 @@ async function main() {
321
338
  else {
322
339
  process.stdout.write(`Dashboard server started at http://127.0.0.1:${options.dashboardPort} (PID ${dashboard.pid})\n`);
323
340
  }
341
+ await maybeShowUpdateNotice();
324
342
  }
325
343
  return;
326
344
  }
@@ -332,6 +350,7 @@ async function main() {
332
350
  ? formatStatsChart(summary)
333
351
  : formatStats(summary);
334
352
  process.stdout.write(`${output}\n`);
353
+ await maybeShowUpdateNotice();
335
354
  return;
336
355
  }
337
356
  if (options.mode === "track") {
@@ -368,6 +387,7 @@ async function main() {
368
387
  if (options.mode === "pipe") {
369
388
  const input = await readStdin();
370
389
  await render(input, options);
390
+ await maybeShowUpdateNotice();
371
391
  return;
372
392
  }
373
393
  const [command, ...args] = options.command;
@@ -376,6 +396,8 @@ async function main() {
376
396
  const result = await runCommand(command, args);
377
397
  await render(result.combined, options, result.durationMs, result.exitCode);
378
398
  process.exitCode = result.exitCode;
399
+ if (result.exitCode === 0)
400
+ await maybeShowUpdateNotice();
379
401
  }
380
402
  main().catch((error) => {
381
403
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  export function defaultConfigPath() {
@@ -35,6 +35,28 @@ export function resolveSetting(key, configPath) {
35
35
  return fromEnvironment;
36
36
  return readConfig(configPath).get(key);
37
37
  }
38
+ export function setConfigValue(key, value, configPath = defaultConfigPath()) {
39
+ const directory = path.dirname(configPath);
40
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
41
+ let lines = [];
42
+ try {
43
+ lines = readFileSync(configPath, "utf8").split(/\r?\n/);
44
+ }
45
+ catch { }
46
+ let replaced = false;
47
+ const nextLines = lines.map((line) => {
48
+ const withoutComment = line.split("#")[0]?.trim() ?? "";
49
+ const separator = withoutComment.indexOf("=");
50
+ const existingKey = separator > 0 ? withoutComment.slice(0, separator).trim() : "";
51
+ if (existingKey !== key)
52
+ return line;
53
+ replaced = true;
54
+ return `${key}=${value}`;
55
+ }).filter((line, index, array) => line !== "" || index < array.length - 1);
56
+ if (!replaced)
57
+ nextLines.push(`${key}=${value}`);
58
+ writeFileSync(configPath, `${nextLines.join("\n")}\n`, "utf8");
59
+ }
38
60
  export function isTruthy(value) {
39
61
  const normalized = value?.trim().toLowerCase();
40
62
  return normalized === "1" || normalized === "true" || normalized === "yes";
@@ -0,0 +1,144 @@
1
+ import { defaultConfigPath, isTruthy, readConfig, resolveSetting, setConfigValue } from "../config.js";
2
+ const DEFAULT_REPOSITORY = "ivanduplenskikh/shrinker";
3
+ const DEFAULT_INTERVAL_HOURS = 24;
4
+ const DEFAULT_TIMEOUT_MS = 750;
5
+ const LAST_CHECK_KEY = "SHRINKER_LAST_UPDATE_CHECK";
6
+ const LATEST_VERSION_KEY = "SHRINKER_LATEST_VERSION";
7
+ const NOTICE_SHOWN_KEY = "SHRINKER_UPDATE_NOTICE_SHOWN";
8
+ function normalizeVersion(value) {
9
+ const normalized = value?.trim().replace(/^v/, "");
10
+ return normalized && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(normalized) ? normalized : undefined;
11
+ }
12
+ function parseVersion(value) {
13
+ const normalized = normalizeVersion(value);
14
+ if (!normalized)
15
+ return undefined;
16
+ const [core, suffix] = normalized.split(/[-+]/, 2);
17
+ const parts = core?.split(".").map((part) => Number(part)) ?? [];
18
+ const major = parts[0];
19
+ const minor = parts[1];
20
+ const patch = parts[2];
21
+ if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch))
22
+ return undefined;
23
+ const parsedMajor = major;
24
+ const parsedMinor = minor;
25
+ const parsedPatch = patch;
26
+ return {
27
+ major: parsedMajor,
28
+ minor: parsedMinor,
29
+ patch: parsedPatch,
30
+ ...(suffix ? { prerelease: suffix } : {}),
31
+ };
32
+ }
33
+ export function compareVersions(current, latest) {
34
+ const left = parseVersion(current);
35
+ const right = parseVersion(latest);
36
+ if (!left || !right)
37
+ return 0;
38
+ for (const key of ["major", "minor", "patch"]) {
39
+ if (left[key] < right[key])
40
+ return -1;
41
+ if (left[key] > right[key])
42
+ return 1;
43
+ }
44
+ if (left.prerelease && !right.prerelease)
45
+ return -1;
46
+ if (!left.prerelease && right.prerelease)
47
+ return 1;
48
+ if ((left.prerelease ?? "") < (right.prerelease ?? ""))
49
+ return -1;
50
+ if ((left.prerelease ?? "") > (right.prerelease ?? ""))
51
+ return 1;
52
+ return 0;
53
+ }
54
+ function updateChecksEnabled(configPath) {
55
+ const setting = resolveSetting("SHRINKER_UPDATE_CHECK", configPath);
56
+ if (setting === undefined)
57
+ return true;
58
+ return isTruthy(setting);
59
+ }
60
+ function intervalHours(configPath, fallback) {
61
+ const configured = Number(resolveSetting("SHRINKER_UPDATE_CHECK_INTERVAL_HOURS", configPath));
62
+ return Number.isFinite(configured) && configured >= 0 ? configured : fallback;
63
+ }
64
+ function cachedResult(currentVersion, latestVersion) {
65
+ const latest = normalizeVersion(latestVersion);
66
+ return {
67
+ updateAvailable: compareVersions(currentVersion, latest) < 0,
68
+ ...(currentVersion ? { currentVersion } : {}),
69
+ ...(latest ? { latestVersion: latest } : {}),
70
+ checked: false,
71
+ };
72
+ }
73
+ function isCacheFresh(config, now, maxAgeHours) {
74
+ const previous = Number(config.get(LAST_CHECK_KEY));
75
+ if (!Number.isFinite(previous) || previous <= 0)
76
+ return false;
77
+ return now - previous < maxAgeHours * 60 * 60 * 1000;
78
+ }
79
+ async function fetchLatestRelease(repository, timeoutMs, fetchImpl) {
80
+ const response = await fetchImpl(`https://api.github.com/repos/${repository}/releases/latest`, {
81
+ headers: { "Accept": "application/vnd.github+json", "User-Agent": "shrinker-update-check" },
82
+ signal: AbortSignal.timeout(timeoutMs),
83
+ });
84
+ if (!response.ok)
85
+ return undefined;
86
+ const body = await response.json();
87
+ const version = normalizeVersion(typeof body.tag_name === "string" ? body.tag_name : undefined);
88
+ if (!version)
89
+ return undefined;
90
+ return {
91
+ version,
92
+ ...(typeof body.html_url === "string" ? { releaseUrl: body.html_url } : {}),
93
+ };
94
+ }
95
+ export async function checkForUpdate(options = {}) {
96
+ const configPath = options.configPath ?? defaultConfigPath();
97
+ const currentVersion = normalizeVersion(options.currentVersion);
98
+ if (!currentVersion || !updateChecksEnabled(configPath)) {
99
+ return { updateAvailable: false, ...(currentVersion ? { currentVersion } : {}), checked: false };
100
+ }
101
+ const now = options.now ?? Date.now();
102
+ const config = readConfig(configPath);
103
+ const maxAgeHours = options.intervalHours ?? intervalHours(configPath, DEFAULT_INTERVAL_HOURS);
104
+ if (isCacheFresh(config, now, maxAgeHours)) {
105
+ return cachedResult(currentVersion, config.get(LATEST_VERSION_KEY));
106
+ }
107
+ try {
108
+ const latest = await fetchLatestRelease(options.repository ?? DEFAULT_REPOSITORY, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.fetchImpl ?? fetch);
109
+ if (!latest?.version)
110
+ return { updateAvailable: false, currentVersion, checked: true };
111
+ setConfigValue(LAST_CHECK_KEY, String(now), configPath);
112
+ setConfigValue(LATEST_VERSION_KEY, latest.version, configPath);
113
+ return {
114
+ updateAvailable: compareVersions(currentVersion, latest.version) < 0,
115
+ currentVersion,
116
+ latestVersion: latest.version,
117
+ ...(latest.releaseUrl ? { releaseUrl: latest.releaseUrl } : {}),
118
+ checked: true,
119
+ };
120
+ }
121
+ catch {
122
+ return { updateAvailable: false, currentVersion, checked: true };
123
+ }
124
+ }
125
+ export function formatUpdateNotice(result) {
126
+ if (!result.updateAvailable || !result.currentVersion || !result.latestVersion)
127
+ return undefined;
128
+ const releaseUrl = result.releaseUrl ?? `https://github.com/${DEFAULT_REPOSITORY}/releases/latest`;
129
+ const installCommand = process.platform === "win32"
130
+ ? `& ([scriptblock]::Create((irm https://raw.githubusercontent.com/${DEFAULT_REPOSITORY}/main/integrations/windows/install.ps1))) -Version ${result.latestVersion}`
131
+ : `curl -fsSL https://raw.githubusercontent.com/${DEFAULT_REPOSITORY}/main/integrations/macos/install.sh | bash -s -- --version ${result.latestVersion}`;
132
+ return [
133
+ `[shrinker] Update available: ${result.currentVersion} -> ${result.latestVersion}`,
134
+ `[shrinker] Release: ${releaseUrl}`,
135
+ `[shrinker] Install: ${installCommand}`,
136
+ ].join("\n");
137
+ }
138
+ export function markUpdateNoticeShown(latestVersion, configPath = defaultConfigPath()) {
139
+ setConfigValue(NOTICE_SHOWN_KEY, latestVersion, configPath);
140
+ }
141
+ export function wasUpdateNoticeShown(latestVersion, configPath = defaultConfigPath()) {
142
+ return readConfig(configPath).get(NOTICE_SHOWN_KEY) === latestVersion;
143
+ }
144
+ //# sourceMappingURL=check.js.map
@@ -0,0 +1,66 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
6
+ let cachedVersion;
7
+ export function isPackagedBinary() {
8
+ return Boolean(process.pkg);
9
+ }
10
+ function normalizeVersion(value) {
11
+ if (typeof value !== "string")
12
+ return undefined;
13
+ const normalized = value.trim().replace(/^v/, "");
14
+ return VERSION_PATTERN.test(normalized) ? normalized : undefined;
15
+ }
16
+ function readVersionFile(filePath) {
17
+ try {
18
+ const metadata = JSON.parse(readFileSync(filePath, "utf8"));
19
+ return normalizeVersion(metadata.version);
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ function findPackageJsonVersion(startDirectory) {
26
+ let directory = startDirectory;
27
+ while (true) {
28
+ const packagePath = path.join(directory, "package.json");
29
+ const version = readVersionFile(packagePath);
30
+ if (version)
31
+ return version;
32
+ const parent = path.dirname(directory);
33
+ if (parent === directory)
34
+ return undefined;
35
+ directory = parent;
36
+ }
37
+ }
38
+ function findManifestVersion() {
39
+ const executableDirectory = path.dirname(process.execPath);
40
+ for (const candidate of [
41
+ path.join(executableDirectory, "..", "manifest.json"),
42
+ path.join(executableDirectory, "manifest.json"),
43
+ path.join(process.cwd(), "manifest.json"),
44
+ ]) {
45
+ const resolved = path.resolve(candidate);
46
+ if (!existsSync(resolved))
47
+ continue;
48
+ const version = readVersionFile(resolved);
49
+ if (version)
50
+ return version;
51
+ }
52
+ return undefined;
53
+ }
54
+ export function getCurrentVersion() {
55
+ if (cachedVersion)
56
+ return cachedVersion;
57
+ const manifestVersion = findManifestVersion();
58
+ if (manifestVersion) {
59
+ cachedVersion = manifestVersion;
60
+ return cachedVersion;
61
+ }
62
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
63
+ cachedVersion = findPackageJsonVersion(moduleDirectory);
64
+ return cachedVersion;
65
+ }
66
+ //# sourceMappingURL=version.js.map
@@ -137,6 +137,7 @@ function Install-ReleasePackage {
137
137
  Invoke-WebRequest -Uri $assetUrl -OutFile $archivePath -UseBasicParsing
138
138
  New-Item -ItemType Directory -Force -Path $extractPath | Out-Null
139
139
  Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force
140
+ New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
140
141
 
141
142
  foreach ($name in @("bin", "integrations", "templates")) {
142
143
  $source = Join-Path $extractPath $name
@@ -154,6 +155,11 @@ function Install-ReleasePackage {
154
155
 
155
156
  $binPath = Join-Path $InstallDir "bin"
156
157
  $exePath = Join-Path $binPath "shrinker.exe"
158
+ $legacyExePath = Join-Path $InstallDir "shrinker.exe"
159
+ if (-not (Test-Path -LiteralPath $exePath) -and (Test-Path -LiteralPath $legacyExePath)) {
160
+ New-Item -ItemType Directory -Force -Path $binPath | Out-Null
161
+ Move-Item -LiteralPath $legacyExePath -Destination $exePath -Force
162
+ }
157
163
  if (-not (Test-Path -LiteralPath $exePath)) { throw "Installed executable not found: $exePath" }
158
164
  Add-UserPathEntry $binPath
159
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shrinker-ai",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Local deterministic command-output shrinker for coding agents",
5
5
  "type": "module",
6
6
  "bin": {