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 +16 -0
- package/dist/src/cli.js +22 -0
- package/dist/src/config.js +23 -1
- package/dist/src/updates/check.js +144 -0
- package/dist/src/version.js +66 -0
- package/integrations/windows/install.ps1 +6 -0
- package/package.json +1 -1
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`);
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
|