shrinker-ai 0.4.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/.gitattributes ADDED
@@ -0,0 +1 @@
1
+ *.sh text eol=lf
package/README.md CHANGED
@@ -16,7 +16,9 @@ this POC proves that a few conservative, deterministic filters can save useful c
16
16
 
17
17
  ## Quick start
18
18
 
19
- Requires Node.js 22.13 or newer. This is the first Node 22 release where the built-in SQLite module no longer requires an experimental flag.
19
+ The recommended customer install downloads a standalone binary from GitHub Releases. It does not require npm registry access or a local Node.js installation.
20
+
21
+ Contributor and npm-based installs still require Node.js 22.13 or newer. This is the first Node 22 release where the built-in SQLite module no longer requires an experimental flag.
20
22
 
21
23
  ### One-command install (macOS zsh)
22
24
 
@@ -24,17 +26,59 @@ Requires Node.js 22.13 or newer. This is the first Node 22 release where the bui
24
26
  curl -fsSL https://raw.githubusercontent.com/ivanduplenskikh/shrinker/main/integrations/macos/install.sh | bash
25
27
  ```
26
28
 
29
+ To pin a version:
30
+
31
+ ```bash
32
+ curl -fsSL https://raw.githubusercontent.com/ivanduplenskikh/shrinker/main/integrations/macos/install.sh | bash -s -- --version 0.4.0
33
+ ```
34
+
27
35
  ### One-command install (Windows PowerShell)
28
36
 
29
37
  ```powershell
30
38
  irm https://raw.githubusercontent.com/ivanduplenskikh/shrinker/main/integrations/windows/install.ps1 | iex
31
39
  ```
32
40
 
33
- ### Install from npm package
41
+ To pin a version:
42
+
43
+ ```powershell
44
+ & ([scriptblock]::Create((irm https://raw.githubusercontent.com/ivanduplenskikh/shrinker/main/integrations/windows/install.ps1))) -Version 0.4.0
45
+ ```
46
+
47
+ The installer downloads these anonymous GitHub Release assets by default:
48
+
49
+ - `shrinker-win-x64.zip`
50
+ - `shrinker-macos-arm64.tar.gz`
51
+ - `shrinker-macos-x64.tar.gz`
52
+ - `shrinker-linux-x64.tar.gz`
53
+
54
+ Network allowlists need access to `raw.githubusercontent.com` for the installer script and `github.com/ivanduplenskikh/shrinker/releases/download/...` for release assets.
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
+
72
+ ### Optional npm package install
73
+
74
+ Use this only when npm registry access is available or preferred.
34
75
 
35
76
  Windows PowerShell:
36
77
 
37
78
  ```powershell
79
+ pwsh -ExecutionPolicy Bypass -File .\integrations\windows\install.ps1 -UseNpm
80
+
81
+ # Or manually:
38
82
  npm install --global shrinker-ai --registry=https://registry.npmjs.org
39
83
  $pkg = Join-Path ((npm root --global).Trim()) "shrinker-ai"
40
84
  pwsh -ExecutionPolicy Bypass -File (Join-Path $pkg "integrations\\windows\\install.ps1") -Local -SkipNpmInstall -SkipBuild -SkipLink
@@ -45,6 +89,9 @@ To enable automatic PowerShell routing, add `-EnableProfileRouting` to the final
45
89
  macOS zsh:
46
90
 
47
91
  ```bash
92
+ bash ./integrations/macos/install.sh --use-npm
93
+
94
+ # Or manually:
48
95
  npm install --global shrinker-ai --registry=https://registry.npmjs.org
49
96
  pkg="$(npm root --global)/shrinker-ai"
50
97
  bash "$pkg/integrations/macos/install.sh" --local --skip-npm-install --skip-build --skip-link --enable-profile-routing
@@ -75,7 +122,21 @@ The rules tell agents to prefer `shrinker <command>` for high-volume commands wh
75
122
 
76
123
  ### Uninstall
77
124
 
78
- If you installed from npm, remove the package first:
125
+ GitHub Release binary install:
126
+
127
+ Windows PowerShell:
128
+
129
+ ```powershell
130
+ pwsh -ExecutionPolicy Bypass -File .\integrations\windows\uninstall.ps1
131
+ ```
132
+
133
+ macOS zsh:
134
+
135
+ ```bash
136
+ bash ./integrations/macos/uninstall.sh
137
+ ```
138
+
139
+ If you installed from npm, use npm mode:
79
140
 
80
141
  ```bash
81
142
  npm uninstall --global shrinker-ai --registry=https://registry.npmjs.org
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";
@@ -8,6 +8,12 @@ import { DASHBOARD_STATS_PLACEHOLDER, DASHBOARD_TEMPLATE_HTML } from "./dashboar
8
8
  const execFileAsync = promisify(execFile);
9
9
  // Identifies a running server as ours without depending on user-visible copy.
10
10
  const DASHBOARD_MARKER = 'name="generator" content="shrinker-dashboard"';
11
+ function getCliRelaunchCommand(args) {
12
+ if (process.pkg || !process.argv[1]) {
13
+ return { command: process.execPath, args };
14
+ }
15
+ return { command: process.execPath, args: [process.argv[1], ...args] };
16
+ }
11
17
  // Neutralizes `</script>` inside string fields so the payload cannot break out of the JSON block.
12
18
  function serializePayload(summary) {
13
19
  const payload = {
@@ -146,7 +152,8 @@ export async function startStatsDashboard(port = 4317, restart = false) {
146
152
  openStatsDashboard(url);
147
153
  return { pid: 0, reused: true, restarted: false };
148
154
  }
149
- const child = spawn(process.execPath, [process.argv[1] ?? "", "stats", "--dashboard", "--dashboard-server", "--port", String(port)], { detached: true, stdio: "ignore" });
155
+ const command = getCliRelaunchCommand(["stats", "--dashboard", "--dashboard-server", "--port", String(port)]);
156
+ const child = spawn(command.command, command.args, { detached: true, stdio: "ignore" });
150
157
  child.unref();
151
158
  return { pid: child.pid ?? 0, reused: false, restarted };
152
159
  }
@@ -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
@@ -4,6 +4,9 @@ set -euo pipefail
4
4
  PACKAGE_NAME="shrinker-ai"
5
5
  REGISTRY="https://registry.npmjs.org"
6
6
  VERSION=""
7
+ USE_NPM=0
8
+ RELEASE_REPO="ivanduplenskikh/shrinker"
9
+ ASSET_BASE_URL=""
7
10
  LOCAL=0
8
11
  SKIP_NPM_INSTALL=0
9
12
  SKIP_BUILD=0
@@ -16,6 +19,7 @@ CLAUDE_ONLY=0
16
19
  TRACK_UNCOVERED=""
17
20
  PROFILE_PATH="${PROFILE_PATH:-$HOME/.zshrc}"
18
21
  CONFIG_PATH="${SHRINKER_CONFIG_PATH:-$HOME/.shrinker/config}"
22
+ INSTALL_DIR="${SHRINKER_INSTALL_DIR:-$HOME/.shrinker}"
19
23
  STEP="${SHRINKER_STEP_OFFSET:-0}"
20
24
 
21
25
  print_message() {
@@ -28,9 +32,13 @@ print_message() {
28
32
  while [[ $# -gt 0 ]]; do
29
33
  case "$1" in
30
34
  --local) LOCAL=1 ;;
35
+ --use-npm) USE_NPM=1 ;;
31
36
  --package-name) PACKAGE_NAME="${2:-}"; shift ;;
32
37
  --registry) REGISTRY="${2:-}"; shift ;;
33
38
  --version) VERSION="${2:-}"; shift ;;
39
+ --release-repo) RELEASE_REPO="${2:-}"; shift ;;
40
+ --asset-base-url) ASSET_BASE_URL="${2:-}"; shift ;;
41
+ --install-dir) INSTALL_DIR="${2:-}"; shift ;;
34
42
  --skip-npm-install) SKIP_NPM_INSTALL=1 ;;
35
43
  --skip-build) SKIP_BUILD=1 ;;
36
44
  --skip-link) SKIP_LINK=1 ;;
@@ -83,7 +91,7 @@ if (( interactive && SKIP_PROFILE == 0 && ENABLE_PROFILE_ROUTING == 0 )); then
83
91
  ENABLE_PROFILE_ROUTING="$(prompt_yes_no "Enable automatic shell routing?" 0)"
84
92
  fi
85
93
 
86
- if (( LOCAL == 0 )); then
94
+ if (( LOCAL == 0 && USE_NPM == 1 )); then
87
95
  package_spec="$PACKAGE_NAME"
88
96
  [[ -z "$VERSION" ]] || package_spec="$PACKAGE_NAME@$VERSION"
89
97
  print_message "📦" "Installing $package_spec from $REGISTRY..."
@@ -112,11 +120,13 @@ INTEGRATION_PATH="$SCRIPT_DIR/shrinker-profile.zsh"
112
120
  BLOCK_START="<!-- shrinker agent rules start -->"
113
121
  BLOCK_END="<!-- shrinker agent rules end -->"
114
122
 
115
- node_version_raw="$(node -v 2>/dev/null || true)"
116
- [[ -n "$node_version_raw" ]] || { print_message "❌" "Node.js was not found on PATH. Install Node.js 22.13+ first." >&2; exit 1; }
117
- node_version="${node_version_raw#v}"
118
- IFS='.' read -r node_major node_minor _ <<< "$node_version"
119
- (( node_major > 22 || (node_major == 22 && node_minor >= 13) )) || { print_message "❌" "Node.js 22.13+ is required. Found $node_version_raw" >&2; exit 1; }
123
+ if (( LOCAL == 1 )); then
124
+ node_version_raw="$(node -v 2>/dev/null || true)"
125
+ [[ -n "$node_version_raw" ]] || { print_message "❌" "Node.js was not found on PATH. Install Node.js 22.13+ first." >&2; exit 1; }
126
+ node_version="${node_version_raw#v}"
127
+ IFS='.' read -r node_major node_minor _ <<< "$node_version"
128
+ (( node_major > 22 || (node_major == 22 && node_minor >= 13) )) || { print_message "❌" "Node.js 22.13+ is required. Found $node_version_raw" >&2; exit 1; }
129
+ fi
120
130
 
121
131
  set_agent_rules() {
122
132
  local target="$1"
@@ -161,6 +171,19 @@ add_profile_integration() {
161
171
  fi
162
172
  }
163
173
 
174
+ add_path_integration() {
175
+ local profile_file="$1"
176
+ mkdir -p "$(dirname "$profile_file")"; touch "$profile_file"
177
+ if ! grep -Fq '# >>> shrinker path >>>' "$profile_file"; then
178
+ printf '\n# >>> shrinker path >>>\nexport PATH="%s/bin:$PATH"\n# <<< shrinker path <<<\n\n' "$INSTALL_DIR" >> "$profile_file"
179
+ print_message "🧭" "Added shrinker to PATH in profile: $profile_file"
180
+ fi
181
+ case ":$PATH:" in
182
+ *":$INSTALL_DIR/bin:"*) ;;
183
+ *) export PATH="$INSTALL_DIR/bin:$PATH" ;;
184
+ esac
185
+ }
186
+
164
187
  set_config_value() {
165
188
  local key="$1"
166
189
  local value="$2"
@@ -175,6 +198,73 @@ set_config_value() {
175
198
  print_message "⚙️" "Set $key=$value in: $CONFIG_PATH"
176
199
  }
177
200
 
201
+ release_target() {
202
+ case "$(uname -s)-$(uname -m)" in
203
+ Darwin-arm64) printf '%s' "macos-arm64" ;;
204
+ Darwin-x86_64) printf '%s' "macos-x64" ;;
205
+ Linux-x86_64) printf '%s' "linux-x64" ;;
206
+ *) print_message "❌" "Unsupported release platform: $(uname -s)-$(uname -m)" >&2; exit 1 ;;
207
+ esac
208
+ }
209
+
210
+ release_asset_url() {
211
+ local target asset tag
212
+ target="$(release_target)"
213
+ asset="shrinker-$target.tar.gz"
214
+ if [[ -n "$ASSET_BASE_URL" ]]; then
215
+ printf '%s/%s' "${ASSET_BASE_URL%/}" "$asset"
216
+ elif [[ -n "$VERSION" ]]; then
217
+ tag="$VERSION"
218
+ [[ "$tag" == v* ]] || tag="v$tag"
219
+ printf 'https://github.com/%s/releases/download/%s/%s' "$RELEASE_REPO" "$tag" "$asset"
220
+ else
221
+ printf 'https://github.com/%s/releases/latest/download/%s' "$RELEASE_REPO" "$asset"
222
+ fi
223
+ }
224
+
225
+ install_release_package() {
226
+ local asset_url archive extract_dir name
227
+ asset_url="$(release_asset_url)"
228
+ archive="$(mktemp -t shrinker-release.XXXXXX).tar.gz"
229
+ extract_dir="$(mktemp -d -t shrinker-install.XXXXXX)"
230
+ trap 'rm -f "$archive"; rm -rf "$extract_dir"' RETURN
231
+
232
+ print_message "📦" "Downloading shrinker from: $asset_url"
233
+ curl -fL "$asset_url" -o "$archive"
234
+ tar -xzf "$archive" -C "$extract_dir"
235
+ mkdir -p "$INSTALL_DIR"
236
+ for name in bin integrations templates; do
237
+ [[ -d "$extract_dir/$name" ]] || { print_message "❌" "Release archive is missing: $name" >&2; exit 1; }
238
+ rm -rf "$INSTALL_DIR/$name"
239
+ cp -R "$extract_dir/$name" "$INSTALL_DIR/"
240
+ done
241
+ [[ -f "$extract_dir/manifest.json" ]] && cp "$extract_dir/manifest.json" "$INSTALL_DIR/manifest.json"
242
+ [[ -x "$INSTALL_DIR/bin/shrinker" ]] || chmod +x "$INSTALL_DIR/bin/shrinker"
243
+ [[ -x "$INSTALL_DIR/bin/shrinker" ]] || { print_message "❌" "Installed executable not found: $INSTALL_DIR/bin/shrinker" >&2; exit 1; }
244
+
245
+ add_path_integration "$PROFILE_PATH"
246
+ set_config_value "SHRINKER_TRACK_UNCOVERED" "$TRACK_UNCOVERED"
247
+ if (( SKIP_PROFILE == 0 && ENABLE_PROFILE_ROUTING == 1 )); then add_profile_integration "$PROFILE_PATH"; fi
248
+ if (( SKIP_AGENT_RULES == 0 )); then
249
+ [[ -f "$INSTALL_DIR/templates/agent-rules.md" ]] || { print_message "❌" "Agent rules template not found: $INSTALL_DIR/templates/agent-rules.md" >&2; exit 1; }
250
+ rules_body="$(cat "$INSTALL_DIR/templates/agent-rules.md")"
251
+ (( CLAUDE_ONLY )) || set_agent_rules "$HOME/.copilot/copilot-instructions.md" "$rules_body"
252
+ (( COPILOT_ONLY )) || set_agent_rules "$HOME/.claude/CLAUDE.md" "$rules_body"
253
+ fi
254
+
255
+ print_message "✅" "Install complete."
256
+ echo ""
257
+ echo "💡 Try: shrinker help"
258
+ }
259
+
260
+ if (( LOCAL == 0 )); then
261
+ install_release_package
262
+ if [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != "$0" ]]; then
263
+ return 0
264
+ fi
265
+ exit 0
266
+ fi
267
+
178
268
  print_message "📦" "Installing shrinker from: $REPO_ROOT"
179
269
  pushd "$REPO_ROOT" >/dev/null
180
270
  if (( SKIP_NPM_INSTALL == 0 )); then
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- INSTALL_DIR="${HOME}/.shrinker-src"
4
+ INSTALL_DIR="${SHRINKER_INSTALL_DIR:-$HOME/.shrinker}"
5
5
  REMOVE_INSTALL_DIR=0
6
+ USE_NPM=0
6
7
  SKIP_UNLINK=0
7
8
  SKIP_AGENT_RULES=0
8
9
  COPILOT_ONLY=0
@@ -22,6 +23,7 @@ print_step() {
22
23
 
23
24
  while [[ $# -gt 0 ]]; do
24
25
  case "$1" in
26
+ --use-npm) USE_NPM=1 ;;
25
27
  --skip-unlink) SKIP_UNLINK=1 ;;
26
28
  --skip-agent-rules) SKIP_AGENT_RULES=1 ;;
27
29
  --copilot-only) COPILOT_ONLY=1 ;;
@@ -54,6 +56,17 @@ remove_profile_integration() {
54
56
  mv "$profile_file.tmp" "$profile_file"
55
57
  }
56
58
 
59
+ remove_path_integration() {
60
+ local profile_file="$1"
61
+ [[ -f "$profile_file" ]] || return 0
62
+ awk -v start='# >>> shrinker path >>>' -v end='# <<< shrinker path <<<' 'BEGIN{inblock=0} {if(index($0,start)>0){inblock=1;next} if(inblock&&index($0,end)>0){inblock=0;next} if(!inblock)print}' "$profile_file" > "$profile_file.tmp"
63
+ mv "$profile_file.tmp" "$profile_file"
64
+ }
65
+
66
+ remove_release_install() {
67
+ rm -rf "$INSTALL_DIR/bin" "$INSTALL_DIR/integrations" "$INSTALL_DIR/templates" "$INSTALL_DIR/manifest.json"
68
+ }
69
+
57
70
  # The dashboard runs as a detached daemon, so unlinking the package never stops it.
58
71
  stop_dashboard_server() {
59
72
  local url="http://127.0.0.1:$DASHBOARD_PORT"
@@ -86,14 +99,18 @@ remove_managed_config() {
86
99
  print_step "🛑" "Stopping the dashboard server on port $DASHBOARD_PORT..."
87
100
  stop_dashboard_server
88
101
 
89
- if (( SKIP_UNLINK == 0 )); then
102
+ if (( SKIP_UNLINK == 0 && USE_NPM == 1 )); then
90
103
  print_step "🔗" "Unlinking shrinker globally..."
91
104
  npm unlink --silent --global shrinker-ai
105
+ elif (( SKIP_UNLINK == 0 )); then
106
+ print_step "🔗" "Removing release-installed shrinker files..."
107
+ remove_release_install
92
108
  else
93
- print_step "⏭️" "Skipped global npm unlink."
109
+ print_step "⏭️" "Skipped unlink/removal."
94
110
  fi
95
111
  print_step "🔧" "Removing shell profile integration..."
96
112
  remove_profile_integration "$PROFILE_PATH"
113
+ remove_path_integration "$PROFILE_PATH"
97
114
  if (( SKIP_AGENT_RULES == 0 )); then
98
115
  (( CLAUDE_ONLY )) || remove_agent_rules "$HOME/.copilot/copilot-instructions.md"
99
116
  (( COPILOT_ONLY )) || remove_agent_rules "$HOME/.claude/CLAUDE.md"
@@ -1,8 +1,12 @@
1
1
  param(
2
2
  [switch]$Local,
3
+ [switch]$UseNpm,
3
4
  [string]$PackageName = "shrinker-ai",
4
5
  [string]$Registry = "https://registry.npmjs.org",
5
6
  [string]$Version,
7
+ [string]$ReleaseRepo = "ivanduplenskikh/shrinker",
8
+ [string]$AssetBaseUrl,
9
+ [string]$InstallDir = $(Join-Path $HOME ".shrinker"),
6
10
  [switch]$SkipNpmInstall,
7
11
  [switch]$SkipBuild,
8
12
  [switch]$SkipLink,
@@ -99,7 +103,82 @@ function Add-ProfileIntegration {
99
103
  Write-InstallStep "🔧" "Added shrinker integration block to profile: $ProfileFile"
100
104
  }
101
105
 
102
- if (-not $Local) {
106
+ function Add-UserPathEntry {
107
+ param([string]$Directory)
108
+ $currentUserPath = [Environment]::GetEnvironmentVariable("Path", "User")
109
+ $entries = @($currentUserPath -split [IO.Path]::PathSeparator | Where-Object { $_ })
110
+ if ($entries -notcontains $Directory) {
111
+ $newPath = (@($entries) + $Directory) -join [IO.Path]::PathSeparator
112
+ [Environment]::SetEnvironmentVariable("Path", $newPath, "User")
113
+ Write-InstallStep "🧭" "Added shrinker to the user PATH: $Directory"
114
+ }
115
+ $processEntries = @($env:Path -split [IO.Path]::PathSeparator | Where-Object { $_ })
116
+ if ($processEntries -notcontains $Directory) {
117
+ $env:Path = (@($processEntries) + $Directory) -join [IO.Path]::PathSeparator
118
+ }
119
+ }
120
+
121
+ function Get-ReleaseAssetUrl {
122
+ $assetName = "shrinker-win-x64.zip"
123
+ if ($AssetBaseUrl) { return "$($AssetBaseUrl.TrimEnd('/'))/$assetName" }
124
+ if ($Version) {
125
+ $tag = if ($Version.StartsWith("v")) { $Version } else { "v$Version" }
126
+ return "https://github.com/$ReleaseRepo/releases/download/$tag/$assetName"
127
+ }
128
+ return "https://github.com/$ReleaseRepo/releases/latest/download/$assetName"
129
+ }
130
+
131
+ function Install-ReleasePackage {
132
+ $assetUrl = Get-ReleaseAssetUrl
133
+ $archivePath = Join-Path ([IO.Path]::GetTempPath()) "shrinker-win-x64.zip"
134
+ $extractPath = Join-Path ([IO.Path]::GetTempPath()) ("shrinker-install-" + [guid]::NewGuid().ToString("N"))
135
+ try {
136
+ Write-InstallStep "📦" "Downloading shrinker from: $assetUrl"
137
+ Invoke-WebRequest -Uri $assetUrl -OutFile $archivePath -UseBasicParsing
138
+ New-Item -ItemType Directory -Force -Path $extractPath | Out-Null
139
+ Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force
140
+ New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
141
+
142
+ foreach ($name in @("bin", "integrations", "templates")) {
143
+ $source = Join-Path $extractPath $name
144
+ if (-not (Test-Path -LiteralPath $source)) { throw "Release archive is missing: $name" }
145
+ Copy-Item -LiteralPath $source -Destination $InstallDir -Recurse -Force
146
+ }
147
+ if (Test-Path -LiteralPath (Join-Path $extractPath "manifest.json")) {
148
+ Copy-Item -LiteralPath (Join-Path $extractPath "manifest.json") -Destination $InstallDir -Force
149
+ }
150
+ }
151
+ finally {
152
+ Remove-Item -LiteralPath $archivePath -Force -ErrorAction SilentlyContinue
153
+ Remove-Item -LiteralPath $extractPath -Recurse -Force -ErrorAction SilentlyContinue
154
+ }
155
+
156
+ $binPath = Join-Path $InstallDir "bin"
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
+ }
163
+ if (-not (Test-Path -LiteralPath $exePath)) { throw "Installed executable not found: $exePath" }
164
+ Add-UserPathEntry $binPath
165
+
166
+ $templatePath = Join-Path $InstallDir "templates\agent-rules.md"
167
+ $integrationPath = Join-Path $InstallDir "integrations\windows\shrinker-profile.ps1"
168
+ if (-not $SkipAgentRules) {
169
+ if (-not (Test-Path $templatePath)) { throw "Agent rules template not found: $templatePath" }
170
+ $rulesBody = Get-Content $templatePath -Raw
171
+ }
172
+
173
+ Set-ConfigValue "SHRINKER_TRACK_UNCOVERED" $(if ($TrackUncovered) { "1" } else { "0" })
174
+ if (-not $SkipProfile -and $EnableProfileRouting) { Add-ProfileIntegration $ProfilePath $integrationPath }
175
+ if (-not $SkipAgentRules) { Set-AgentRules $rulesBody }
176
+ Write-InstallStep "✅" "Install complete."
177
+ Write-Host " "
178
+ Write-Host "💡 Try: shrinker help"
179
+ }
180
+
181
+ if (-not $Local -and $UseNpm) {
103
182
  $packageSpec = if ($Version) { "$PackageName@$Version" } else { $PackageName }
104
183
  Write-InstallStep "📦" "Installing $packageSpec from $Registry..."
105
184
  & npm install --silent --global $packageSpec "--registry=$Registry"
@@ -116,6 +195,11 @@ if (-not $Local) {
116
195
  return
117
196
  }
118
197
 
198
+ if (-not $Local) {
199
+ Install-ReleasePackage
200
+ return
201
+ }
202
+
119
203
  $scriptDir = $PSScriptRoot
120
204
  $repoRoot = (Resolve-Path (Join-Path $scriptDir "..\..")).Path
121
205
  $templatePath = Join-Path $repoRoot "templates\agent-rules.md"
@@ -1,10 +1,12 @@
1
1
  param(
2
+ [switch]$UseNpm,
2
3
  [switch]$SkipUnlink,
3
4
  [switch]$SkipAgentRules,
4
5
  [switch]$CopilotOnly,
5
6
  [switch]$ClaudeOnly,
6
7
  [switch]$PurgeData,
7
8
  [int]$Port = 4317,
9
+ [string]$InstallDir = $(Join-Path $HOME ".shrinker"),
8
10
  [string]$ProfilePath = $PROFILE,
9
11
  [string]$ConfigPath = $(if ($env:SHRINKER_CONFIG_PATH) { $env:SHRINKER_CONFIG_PATH } else { Join-Path $HOME ".shrinker/config" })
10
12
  )
@@ -45,6 +47,25 @@ function Remove-ProfileIntegration {
45
47
  }
46
48
  }
47
49
 
50
+ function Remove-UserPathEntry {
51
+ param([string]$Directory)
52
+ $currentUserPath = [Environment]::GetEnvironmentVariable("Path", "User")
53
+ if (-not $currentUserPath) { return }
54
+ $entries = @($currentUserPath -split [IO.Path]::PathSeparator | Where-Object { $_ -and $_ -ne $Directory })
55
+ $newPath = $entries -join [IO.Path]::PathSeparator
56
+ if ($newPath -ne $currentUserPath) {
57
+ [Environment]::SetEnvironmentVariable("Path", $newPath, "User")
58
+ Write-UninstallStep "🧭" "Removed shrinker from the user PATH: $Directory"
59
+ }
60
+ }
61
+
62
+ function Remove-ReleaseInstall {
63
+ foreach ($name in @("bin", "integrations", "templates", "manifest.json")) {
64
+ Remove-Item -LiteralPath (Join-Path $InstallDir $name) -Recurse -Force -ErrorAction SilentlyContinue
65
+ }
66
+ Remove-UserPathEntry (Join-Path $InstallDir "bin")
67
+ }
68
+
48
69
  # The dashboard runs as a detached daemon, so unlinking the package never stops it.
49
70
  function Stop-DashboardServer {
50
71
  param([int]$DashboardPort)
@@ -72,11 +93,15 @@ function Remove-ManagedConfig {
72
93
  Write-UninstallStep "🛑" "Stopping the dashboard server on port $Port..."
73
94
  Stop-DashboardServer $Port
74
95
 
75
- if (-not $SkipUnlink) {
96
+ if (-not $SkipUnlink -and $UseNpm) {
76
97
  Write-UninstallStep "🔗" "Unlinking shrinker globally..."
77
98
  & npm unlink --silent --global shrinker-ai
78
99
  if ($LASTEXITCODE -ne 0) { throw "npm unlink failed." }
79
100
  }
101
+ elseif (-not $SkipUnlink) {
102
+ Write-UninstallStep "🔗" "Removing release-installed shrinker files..."
103
+ Remove-ReleaseInstall
104
+ }
80
105
  else { Write-UninstallStep "⏭️" "Skipped global npm unlink." }
81
106
  Write-UninstallStep "🔧" "Removing PowerShell profile integration..."
82
107
  Remove-ProfileIntegration $ProfilePath
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shrinker-ai",
3
- "version": "0.4.0",
3
+ "version": "0.9.0",
4
4
  "description": "Local deterministic command-output shrinker for coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,8 @@
17
17
  "dashboard": "npm run build --silent && node dist/src/cli.js stats --dashboard",
18
18
  "dashboard:restart": "npm run build --silent && node dist/src/cli.js stats --dashboard --restart",
19
19
  "pack": "npm run build --silent && npm pack",
20
+ "package:release": "npm run build --silent && node scripts/package-release.mjs",
21
+ "package:release:current": "node scripts/package-release.mjs",
20
22
  "test": "npm run build --silent && node --test \"dist/tests/*.test.js\"",
21
23
  "demo": "npm run build --silent && node dist/demo/run-demo.js",
22
24
  "install:local": "pwsh -ExecutionPolicy Bypass -File ./integrations/windows/install.ps1 -Local",
@@ -30,6 +32,7 @@
30
32
  },
31
33
  "devDependencies": {
32
34
  "@types/node": "^24.0.0",
35
+ "@yao-pkg/pkg": "^6.22.0",
33
36
  "typescript": "^5.9.0"
34
37
  },
35
38
  "engines": {
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import path from "node:path";
7
+ import process from "node:process";
8
+
9
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const packageJson = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8"));
11
+
12
+ const targets = {
13
+ "win-x64": { pkg: "node22-win-x64", archive: "zip", binary: "shrinker.exe" },
14
+ "macos-arm64": { pkg: "node22-macos-arm64", archive: "tar.gz", binary: "shrinker" },
15
+ "macos-x64": { pkg: "node22-macos-x64", archive: "tar.gz", binary: "shrinker" },
16
+ "linux-x64": { pkg: "node22-linux-x64", archive: "tar.gz", binary: "shrinker" },
17
+ };
18
+
19
+ function currentTarget() {
20
+ if (process.platform === "win32" && process.arch === "x64") return "win-x64";
21
+ if (process.platform === "darwin" && process.arch === "arm64") return "macos-arm64";
22
+ if (process.platform === "darwin" && process.arch === "x64") return "macos-x64";
23
+ if (process.platform === "linux" && process.arch === "x64") return "linux-x64";
24
+ throw new Error(`No default release target for ${process.platform}-${process.arch}. Pass --target explicitly.`);
25
+ }
26
+
27
+ function readOption(name) {
28
+ const index = process.argv.indexOf(name);
29
+ if (index === -1) return undefined;
30
+ const value = process.argv[index + 1];
31
+ if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
32
+ return value;
33
+ }
34
+
35
+ function readTarget() {
36
+ const explicit = readOption("--target");
37
+ if (explicit) return explicit;
38
+ const positional = process.argv.slice(2).find((arg) => !arg.startsWith("--"));
39
+ return positional ?? currentTarget();
40
+ }
41
+
42
+ function run(command, args, options = {}) {
43
+ const result = spawnSync(command, args, {
44
+ cwd: repoRoot,
45
+ stdio: "inherit",
46
+ shell: false,
47
+ ...options,
48
+ });
49
+ if (result.status !== 0) {
50
+ throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}`);
51
+ }
52
+ }
53
+
54
+ async function copySupportFiles(stageDir) {
55
+ await cp(
56
+ path.join(repoRoot, "integrations", "windows", "shrinker-profile.ps1"),
57
+ path.join(stageDir, "integrations", "windows", "shrinker-profile.ps1"),
58
+ { recursive: true },
59
+ );
60
+ await cp(
61
+ path.join(repoRoot, "integrations", "macos", "shrinker-profile.zsh"),
62
+ path.join(stageDir, "integrations", "macos", "shrinker-profile.zsh"),
63
+ { recursive: true },
64
+ );
65
+ await cp(
66
+ path.join(repoRoot, "templates", "agent-rules.md"),
67
+ path.join(stageDir, "templates", "agent-rules.md"),
68
+ { recursive: true },
69
+ );
70
+ }
71
+
72
+ async function createArchive(stageDir, archivePath, archiveType) {
73
+ await rm(archivePath, { force: true });
74
+ if (archiveType === "zip") {
75
+ const expression = [
76
+ "$ErrorActionPreference = 'Stop'",
77
+ `$source = Join-Path '${stageDir.replaceAll("'", "''")}' '*'`,
78
+ `Compress-Archive -Path $source -DestinationPath '${archivePath.replaceAll("'", "''")}' -Force`,
79
+ ].join("; ");
80
+ run("pwsh", ["-NoProfile", "-Command", expression]);
81
+ return;
82
+ }
83
+
84
+ run("tar", ["-czf", archivePath, "-C", stageDir, "."]);
85
+ }
86
+
87
+ const targetName = readTarget();
88
+ const version = readOption("--version") ?? packageJson.version;
89
+ const target = targets[targetName];
90
+ if (!target) {
91
+ throw new Error(`Unsupported target '${targetName}'. Supported targets: ${Object.keys(targets).join(", ")}`);
92
+ }
93
+
94
+ const entrypoint = path.join(repoRoot, "dist", "src", "cli.js");
95
+ if (!existsSync(entrypoint)) {
96
+ throw new Error("Missing dist/src/cli.js. Run npm run build before packaging.");
97
+ }
98
+
99
+ const releaseDir = path.join(repoRoot, "release");
100
+ const stageDir = path.join(repoRoot, ".shrinker", "package", targetName);
101
+ const binaryPath = path.join(stageDir, "bin", target.binary);
102
+ const archiveName = `shrinker-${targetName}.${target.archive === "zip" ? "zip" : "tar.gz"}`;
103
+ const archivePath = path.join(releaseDir, archiveName);
104
+
105
+ await rm(stageDir, { recursive: true, force: true });
106
+ await mkdir(path.dirname(binaryPath), { recursive: true });
107
+ await mkdir(releaseDir, { recursive: true });
108
+
109
+ run(process.execPath, [
110
+ path.join(repoRoot, "node_modules", "@yao-pkg", "pkg", "lib-es5", "bin.js"),
111
+ entrypoint,
112
+ "--targets",
113
+ target.pkg,
114
+ "--output",
115
+ binaryPath,
116
+ "--no-bytecode",
117
+ "--public",
118
+ "--public-packages",
119
+ "*",
120
+ ]);
121
+ await copySupportFiles(stageDir);
122
+ await writeFile(
123
+ path.join(stageDir, "manifest.json"),
124
+ `${JSON.stringify({ name: packageJson.name, version, target: targetName, binary: `bin/${target.binary}` }, null, 2)}\n`,
125
+ "utf8",
126
+ );
127
+
128
+ await createArchive(stageDir, archivePath, target.archive);
129
+ console.log(`Created ${path.relative(repoRoot, archivePath)}`);