shakers-cli-beta 0.10.4 → 0.12.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.
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ // `update` is explicit and user-invoked (never a hot path), so these timeouts can be generous.
9
+ const VIEW_TIMEOUT_MS = 20000;
10
+ const INSTALL_TIMEOUT_MS = 180000;
11
+
12
+ // Documented installer one-liner (README `curl | bash`, `release` branch); overridable for a non-default deployment.
13
+ const DEFAULT_INSTALL_URL =
14
+ 'https://raw.githubusercontent.com/devShakers/ai-usage-evaluator/release/install.sh';
15
+
16
+ // Name/version/registry come from THIS package.json, so a renamed flavor adapts with no code change here.
17
+ function readPackageInfo() {
18
+ try {
19
+ const pkg = require(path.join(__dirname, '..', 'package.json'));
20
+ const registry =
21
+ pkg.publishConfig && typeof pkg.publishConfig.registry === 'string'
22
+ ? pkg.publishConfig.registry
23
+ : null;
24
+ return {
25
+ name: typeof pkg.name === 'string' ? pkg.name : null,
26
+ version: typeof pkg.version === 'string' ? pkg.version : null,
27
+ registry,
28
+ };
29
+ } catch {
30
+ return { name: null, version: null, registry: null };
31
+ }
32
+ }
33
+
34
+ function realPathOrSelf(p) {
35
+ try {
36
+ return fs.realpathSync(p);
37
+ } catch {
38
+ return p;
39
+ }
40
+ }
41
+
42
+ // Root of the installed package = the parent of this src/ directory.
43
+ function packageRoot() {
44
+ return realPathOrSelf(path.resolve(__dirname, '..'));
45
+ }
46
+
47
+ function npmGlobalRoot() {
48
+ try {
49
+ const out = execFileSync('npm', ['root', '-g'], {
50
+ encoding: 'utf8',
51
+ timeout: VIEW_TIMEOUT_MS,
52
+ stdio: ['ignore', 'pipe', 'ignore'],
53
+ });
54
+ return out ? realPathOrSelf(out.trim()) : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // Detects how this CLI was installed (prefix | npm-global | npm-local | source | unknown) so the update lands where the CLI lives.
61
+ function detectInstall(env = process.env) {
62
+ const root = packageRoot();
63
+ const nodeModulesSeg = `${path.sep}node_modules${path.sep}`;
64
+
65
+ if (!root.includes(nodeModulesSeg)) {
66
+ const home = realPathOrSelf(
67
+ env.SHAKERS_CLI_HOME ||
68
+ env.AI_FOOTPRINT_HOME ||
69
+ path.join(os.homedir(), '.shakers'),
70
+ );
71
+ if (root === home) return { kind: 'prefix', root };
72
+ if (fs.existsSync(path.join(root, '.git'))) return { kind: 'source', root };
73
+ return { kind: 'unknown', root };
74
+ }
75
+
76
+ const info = readPackageInfo();
77
+ const gRoot = npmGlobalRoot();
78
+ const globalPkgPath =
79
+ gRoot && info.name
80
+ ? realPathOrSelf(path.join(gRoot, ...info.name.split('/')))
81
+ : null;
82
+ if (globalPkgPath && root === globalPkgPath) {
83
+ return { kind: 'npm-global', root, global: true, projectDir: null };
84
+ }
85
+ // `.../project/node_modules/<@scope/name>` → the project dir owning the local node_modules is `up` segments up.
86
+ const up = info.name && info.name.includes('/') ? 3 : 2;
87
+ const projectDir = path.resolve(root, ...Array(up).fill('..'));
88
+ return { kind: 'npm-local', root, global: false, projectDir };
89
+ }
90
+
91
+ function classifyError(error) {
92
+ const code = error && error.code;
93
+ const stderr =
94
+ (error && error.stderr && error.stderr.toString()) ||
95
+ (error && error.message) ||
96
+ '';
97
+ if (code === 'ENOENT') return 'npm-not-found';
98
+ if (code === 'ETIMEDOUT' || /ETIMEDOUT|timed out/i.test(stderr))
99
+ return 'timeout';
100
+ if (/EACCES|permission denied|EPERM/i.test(stderr)) return 'permission';
101
+ if (/ENOTFOUND|ECONNREFUSED|network|ECONNRESET|getaddrinfo/i.test(stderr))
102
+ return 'network';
103
+ if (/\b401\b|\b403\b|unauthorized|forbidden|authentication/i.test(stderr))
104
+ return 'auth';
105
+ if (/E404|not found/i.test(stderr)) return 'not-found';
106
+ return 'unknown';
107
+ }
108
+
109
+ // `npm view <name>@latest version` prints only the version string (last line).
110
+ function fetchLatestVersion({ name, registry } = {}) {
111
+ if (!name) return { ok: false, reason: 'no-package-name' };
112
+ const args = ['view', `${name}@latest`, 'version'];
113
+ if (registry) args.push('--registry', registry);
114
+ try {
115
+ const out = execFileSync('npm', args, {
116
+ encoding: 'utf8',
117
+ timeout: VIEW_TIMEOUT_MS,
118
+ stdio: ['ignore', 'pipe', 'pipe'],
119
+ });
120
+ const version = (out || '')
121
+ .trim()
122
+ .split('\n')
123
+ .map((l) => l.trim())
124
+ .filter(Boolean)
125
+ .pop();
126
+ return version ? { ok: true, version } : { ok: false, reason: 'empty-response' };
127
+ } catch (error) {
128
+ return { ok: false, reason: classifyError(error) };
129
+ }
130
+ }
131
+
132
+ function parseSemver(v) {
133
+ const core = String(v).trim().replace(/^v/, '').split('-')[0].split('+')[0];
134
+ return core.split('.').map((n) => parseInt(n, 10) || 0);
135
+ }
136
+
137
+ // -1/0/1 on the semver core; prerelease suffixes ignored (fine for an "is a newer release available" check).
138
+ function compareVersions(a, b) {
139
+ const pa = parseSemver(a);
140
+ const pb = parseSemver(b);
141
+ for (let i = 0; i < 3; i++) {
142
+ const x = pa[i] || 0;
143
+ const y = pb[i] || 0;
144
+ if (x !== y) return x < y ? -1 : 1;
145
+ }
146
+ return 0;
147
+ }
148
+
149
+ function manualCommand({ name, registry } = {}) {
150
+ if (!name) return 'npm install -g <package>@latest';
151
+ const reg = registry ? ` --registry ${registry}` : '';
152
+ return `npm install -g ${name}@latest${reg}`;
153
+ }
154
+
155
+ // Resolves the exact command this install model needs to pull `@latest`.
156
+ function buildUpdatePlan(install, info, env = process.env) {
157
+ const { name, registry } = info;
158
+ if (!name) return null;
159
+ if (install.kind === 'npm-global') {
160
+ const args = ['install', '-g', `${name}@latest`];
161
+ if (registry) args.push('--registry', registry);
162
+ return { type: 'npm', cmd: 'npm', args, cwd: undefined, label: 'npm (global)' };
163
+ }
164
+ if (install.kind === 'npm-local') {
165
+ const args = ['install', `${name}@latest`];
166
+ if (registry) args.push('--registry', registry);
167
+ return {
168
+ type: 'npm',
169
+ cmd: 'npm',
170
+ args,
171
+ cwd: install.projectDir || undefined,
172
+ label: 'npm (local project)',
173
+ };
174
+ }
175
+ if (install.kind === 'prefix') {
176
+ const url = env.SHAKERS_CLI_INSTALL_URL || DEFAULT_INSTALL_URL;
177
+ return {
178
+ type: 'installer',
179
+ url,
180
+ label: 'installer (~/.shakers)',
181
+ command: `curl -fsSL "${url}" | bash`,
182
+ };
183
+ }
184
+ return null;
185
+ }
186
+
187
+ // Inherited stdio so the user sees npm's progress; the installer path keeps SHAKERS_CLI_HOME/BIN so a re-run lands in the same prefix.
188
+ function runInstall(plan, env = process.env) {
189
+ if (!plan) return { ok: false, reason: 'no-plan' };
190
+ try {
191
+ if (plan.type === 'npm') {
192
+ execFileSync(plan.cmd, plan.args, {
193
+ stdio: 'inherit',
194
+ timeout: INSTALL_TIMEOUT_MS,
195
+ cwd: plan.cwd,
196
+ env,
197
+ });
198
+ return { ok: true };
199
+ }
200
+ if (plan.type === 'installer') {
201
+ execFileSync('bash', ['-c', plan.command], {
202
+ stdio: 'inherit',
203
+ timeout: INSTALL_TIMEOUT_MS,
204
+ env,
205
+ });
206
+ return { ok: true };
207
+ }
208
+ return { ok: false, reason: 'no-plan' };
209
+ } catch (error) {
210
+ return { ok: false, reason: classifyError(error) };
211
+ }
212
+ }
213
+
214
+ module.exports = {
215
+ readPackageInfo,
216
+ detectInstall,
217
+ fetchLatestVersion,
218
+ compareVersions,
219
+ buildUpdatePlan,
220
+ runInstall,
221
+ manualCommand,
222
+ DEFAULT_INSTALL_URL,
223
+ };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { spawn } = require('child_process');
7
+ const { getConfigDir } = require('./config-dir');
8
+ const { getCatalog } = require('./i18n');
9
+ const { compareVersions } = require('./update-flow');
10
+
11
+ // Non-blocking update notifier: the hot path only reads the cache synchronously; a detached unref'd worker does the network check.
12
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
13
+
14
+ function cacheFilePath(env = process.env) {
15
+ return path.join(getConfigDir(env), 'update-check.json');
16
+ }
17
+
18
+ // `{ lastCheck, latest }` or null. Never throws — a missing/corrupt cache is "no data".
19
+ function readCache(env = process.env) {
20
+ try {
21
+ const parsed = JSON.parse(fs.readFileSync(cacheFilePath(env), 'utf8'));
22
+ if (!parsed || typeof parsed !== 'object') return null;
23
+ return parsed;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ // Written by the background worker (and nowhere else on the hot path). Best-effort, atomic + 0600 like every other home-dir write (temp-in-same-dir + fsync + rename).
30
+ function writeCache(data, env = process.env) {
31
+ const target = cacheFilePath(env);
32
+ const dir = path.dirname(target);
33
+ const tmp = path.join(dir, `.update-check.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
34
+ let fd = null;
35
+ try {
36
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
37
+ const payload = JSON.stringify({ lastCheck: data.lastCheck, latest: data.latest });
38
+ fd = fs.openSync(tmp, 'wx', 0o600);
39
+ fs.writeFileSync(fd, payload);
40
+ try { fs.fsyncSync(fd); } catch { /* e.g. EINVAL on some filesystems */ }
41
+ fs.closeSync(fd);
42
+ fd = null;
43
+ fs.renameSync(tmp, target);
44
+ return true;
45
+ } catch {
46
+ if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } }
47
+ try { fs.unlinkSync(tmp); } catch { /* never existed */ }
48
+ return false;
49
+ }
50
+ }
51
+
52
+ // `stdoutIsTTY` is injectable for tests; defaults to the real tty bit so piped/redirected/mcp-stdio stdout stays quiet.
53
+ function isSuppressed({
54
+ argv = [],
55
+ command = null,
56
+ env = process.env,
57
+ stdoutIsTTY = process.stdout.isTTY,
58
+ } = {}) {
59
+ if (env.SHAKERS_CLI_NO_UPDATE_NOTIFIER === '1') return true;
60
+ if (env.CI && env.CI !== 'false' && env.CI !== '0') return true;
61
+ if (!stdoutIsTTY) return true;
62
+ if (Array.isArray(argv) && argv.includes('--json')) return true;
63
+ if (command === 'update') return true;
64
+ return false;
65
+ }
66
+
67
+ // Synchronous: prints to stderr when the cached latest beats the installed version, never polluting stdout. Returns the notice (or null).
68
+ function maybeNotify({ currentVersion, lang, env = process.env, out = null } = {}) {
69
+ if (!currentVersion) return null;
70
+ const cache = readCache(env);
71
+ if (!cache || typeof cache.latest !== 'string' || !cache.latest) return null;
72
+ if (compareVersions(currentVersion, cache.latest) >= 0) return null;
73
+ const t = getCatalog(lang).cli.updateNotifier;
74
+ const write = out || ((s) => process.stderr.write(s));
75
+ write(`\n ${t.available(currentVersion, cache.latest)}\n ${t.howTo}\n\n`);
76
+ return t.available(currentVersion, cache.latest);
77
+ }
78
+
79
+ // Spawns the detached, unref'd worker to refresh the cache, but only when it is missing or older than CHECK_INTERVAL_MS. Failures swallowed.
80
+ function triggerBackgroundCheck({ name, env = process.env } = {}) {
81
+ if (!name) return false;
82
+ const cache = readCache(env);
83
+ const now = Date.now();
84
+ if (
85
+ cache &&
86
+ typeof cache.lastCheck === 'number' &&
87
+ now - cache.lastCheck < CHECK_INTERVAL_MS
88
+ ) {
89
+ return false;
90
+ }
91
+ try {
92
+ const worker = path.join(__dirname, '..', 'bin', 'update-check-worker.js');
93
+ const child = spawn(process.execPath, [worker, name], {
94
+ detached: true,
95
+ stdio: 'ignore',
96
+ });
97
+ child.on('error', () => {});
98
+ child.unref();
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ module.exports = {
106
+ cacheFilePath,
107
+ readCache,
108
+ writeCache,
109
+ isSuppressed,
110
+ maybeNotify,
111
+ triggerBackgroundCheck,
112
+ CHECK_INTERVAL_MS,
113
+ };