shakers-cli-beta 0.10.4 → 0.11.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/bin/shakers.js CHANGED
@@ -46,7 +46,11 @@ const { run: runRegister } = require('./register');
46
46
  const { run: runOnboarding } = require('./onboarding');
47
47
  const { run: runConfig } = require('./config');
48
48
  const { run: runMcp } = require('./mcp');
49
+ const { run: runUpdate } = require('./update');
49
50
  const { checkOnboardingCompleted } = require('../src/onboarding-availability');
51
+ const { emitCommandAudit } = require('../src/command-audit');
52
+ const { readPackageInfo } = require('../src/update-flow');
53
+ const { isSuppressed, maybeNotify, triggerBackgroundCheck } = require('../src/update-notifier');
50
54
 
51
55
  let VERSION = '';
52
56
  try {
@@ -95,13 +99,14 @@ const HANDLERS = {
95
99
  register: runRegister,
96
100
  onboarding: runOnboarding,
97
101
  config: runConfig,
102
+ update: runUpdate,
98
103
  login: runLogin,
99
104
  logout: runLogout,
100
105
  superadmin: runSuperadmin,
101
106
  mcp: runMcp,
102
107
  };
103
108
 
104
- const SESSION_EXEMPT = new Set(['ai-usage', 'register', 'config', 'login', 'logout', 'superadmin', 'mcp', 'share', 'alma']);
109
+ const SESSION_EXEMPT = new Set(['ai-usage', 'register', 'config', 'login', 'logout', 'superadmin', 'mcp', 'share', 'alma', 'update']);
105
110
 
106
111
  // Command FAMILIES — a navigation layer over the flat commands (which all stay
107
112
  // valid as aliases). `shakers <family> <sub>` resolves to the flat handler; every
@@ -119,7 +124,7 @@ const FAMILIES = {
119
124
  invitations: 'invitations', applications: 'applications',
120
125
  },
121
126
  usage: { run: 'ai-usage', report: 'report', share: 'share' },
122
- account: { login: 'login', logout: 'logout', register: 'register', config: 'config' },
127
+ account: { login: 'login', logout: 'logout', register: 'register', config: 'config', update: 'update' },
123
128
  certs: { list: 'certifications', certify: 'certify' },
124
129
  };
125
130
 
@@ -212,12 +217,13 @@ function usageText(lang, { hideOnboarding = false } = {}) {
212
217
  + ` Sesión:\n`
213
218
  + ` login Inicia sesión en tu cuenta de Shakers\n`
214
219
  + ` logout Cierra la sesión\n`
215
- + ` config Lee/escribe la configuración (bases de hub/certs)\n\n`
220
+ + ` config Lee/escribe la configuración (bases de hub/certs)\n`
221
+ + ` update Actualiza el CLI a la última versión publicada (--check solo informa)\n\n`
216
222
  + ` Familias (agrupan los comandos de arriba; los nombres planos siguen valiendo como alias):\n`
217
223
  + ` add <skill|agent|project|role> · role <add|change>\n`
218
224
  + ` profile <show|rate|languages|socials|experiences|portfolios|availability|certifications>\n`
219
225
  + ` projects <find|show|save|unsave|invitations|applications>\n`
220
- + ` usage <run|report|share> · certs <list|certify> · account <login|logout|register|config>\n`
226
+ + ` usage <run|report|share> · certs <list|certify> · account <login|logout|register|config|update>\n`
221
227
  + ` Ejecuta \`shakers <familia>\` (sin subcomando) para ver sus subcomandos.\n\n`
222
228
  + ` Ejecuta shakers <comando> --help para las opciones de cada comando.\n\n`;
223
229
  }
@@ -255,12 +261,13 @@ function usageText(lang, { hideOnboarding = false } = {}) {
255
261
  + ` Session:\n`
256
262
  + ` login Sign in to your Shakers account\n`
257
263
  + ` logout Sign out\n`
258
- + ` config Read/write configuration (hub/certs bases)\n\n`
264
+ + ` config Read/write configuration (hub/certs bases)\n`
265
+ + ` update Update the CLI to the latest published version (--check only reports)\n\n`
259
266
  + ` Families (group the commands above; the flat names still work as aliases):\n`
260
267
  + ` add <skill|agent|project|role> · role <add|change>\n`
261
268
  + ` profile <show|rate|languages|socials|experiences|portfolios|availability|certifications>\n`
262
269
  + ` projects <find|show|save|unsave|invitations|applications>\n`
263
- + ` usage <run|report|share> · certs <list|certify> · account <login|logout|register|config>\n`
270
+ + ` usage <run|report|share> · certs <list|certify> · account <login|logout|register|config|update>\n`
264
271
  + ` Run \`shakers <family>\` (no subcommand) to list its subcommands.\n\n`
265
272
  + ` Run \`shakers <command> --help\` for a command's options.\n\n`;
266
273
  }
@@ -295,6 +302,18 @@ async function main() {
295
302
  try { await ensureFreshSession(process.env); } catch { /* never blocks a command */ }
296
303
 
297
304
  const token = argv.find((a) => !a.startsWith('-'));
305
+
306
+ // Single dispatch choke-point (ADR-060): audit EXACTLY once before branching, fire-and-forget (never awaited). MCP tool calls run in-process and never reach here.
307
+ const auditCommand =
308
+ !token || token.toLowerCase() === 'help' ? 'help' : token.toLowerCase();
309
+ emitCommandAudit({ command: auditCommand, argv, env: process.env });
310
+
311
+ // Non-blocking update notice: reads a cached version synchronously, registry check runs in a detached worker; stderr-only, suppressed for --json/non-TTY/CI/update/opt-out.
312
+ if (!isSuppressed({ argv, command: auditCommand, env: process.env })) {
313
+ maybeNotify({ currentVersion: VERSION, lang, env: process.env });
314
+ triggerBackgroundCheck({ name: readPackageInfo().name, env: process.env });
315
+ }
316
+
298
317
  if (!token || token.toLowerCase() === 'help') {
299
318
  let hideOnboarding = false;
300
319
  try {
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Detached worker: fetches the latest version from the PUBLIC, unauthenticated npm registry, writes the `{lastCheck, latest}` cache, fails silent.
5
+
6
+ const https = require('https');
7
+ const { writeCache } = require('../src/update-notifier');
8
+
9
+ const FETCH_TIMEOUT_MS = 10000;
10
+ const MAX_BODY_BYTES = 3 * 1024 * 1024;
11
+
12
+ // Scoped names (`@scope/pkg`) address the registry as `@scope%2fpkg`, `@` literal; install-v1 metadata still carries `dist-tags`.
13
+ function registryUrl(name) {
14
+ const p = name.startsWith('@') ? `@${name.slice(1).replace('/', '%2f')}` : name;
15
+ return `https://registry.npmjs.org/${p}`;
16
+ }
17
+
18
+ function fetchLatest(name, timeoutMs) {
19
+ return new Promise((resolve) => {
20
+ let settled = false;
21
+ const done = (v) => {
22
+ if (!settled) {
23
+ settled = true;
24
+ resolve(v);
25
+ }
26
+ };
27
+ let req;
28
+ try {
29
+ req = https.get(
30
+ registryUrl(name),
31
+ { headers: { Accept: 'application/vnd.npm.install-v1+json' } },
32
+ (res) => {
33
+ if (res.statusCode !== 200) {
34
+ res.resume();
35
+ return done(null);
36
+ }
37
+ let body = '';
38
+ res.setEncoding('utf8');
39
+ res.on('data', (c) => {
40
+ body += c;
41
+ if (body.length > MAX_BODY_BYTES) req.destroy();
42
+ });
43
+ res.on('end', () => {
44
+ try {
45
+ const json = JSON.parse(body);
46
+ const latest = json && json['dist-tags'] && json['dist-tags'].latest;
47
+ done(typeof latest === 'string' && latest ? latest : null);
48
+ } catch {
49
+ done(null);
50
+ }
51
+ });
52
+ },
53
+ );
54
+ } catch {
55
+ return done(null);
56
+ }
57
+ req.setTimeout(timeoutMs, () => req.destroy());
58
+ req.on('error', () => done(null));
59
+ });
60
+ }
61
+
62
+ async function main() {
63
+ const name = process.argv[2];
64
+ if (!name) return;
65
+ const latest = await fetchLatest(name, FETCH_TIMEOUT_MS);
66
+ if (!latest) return;
67
+ writeCache({ lastCheck: Date.now(), latest });
68
+ }
69
+
70
+ main();
package/bin/update.js ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // `update` — update the CLI to the latest published version (CLI-only, never an MCP tool); `--check` reports without installing. Logic in src/update-flow.js.
5
+
6
+ const { detectReportLang, getCatalog } = require('../src/i18n');
7
+ const updateFlow = require('../src/update-flow');
8
+
9
+ function parseUpdateArgs(argv = []) {
10
+ const o = { help: false, check: false, json: false, lang: null };
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const a = argv[i];
13
+ if (a === '--help' || a === '-h') o.help = true;
14
+ else if (a === '--check' || a === '--dry-run') o.check = true;
15
+ else if (a === '--json') o.json = true;
16
+ else if (a === '--lang') {
17
+ const v = argv[++i];
18
+ o.lang = v === 'es' || v === 'en' ? v : null;
19
+ } else if (a.startsWith('--lang=')) {
20
+ const v = a.slice(7);
21
+ o.lang = v === 'es' || v === 'en' ? v : null;
22
+ }
23
+ }
24
+ return o;
25
+ }
26
+
27
+ async function run(
28
+ argv = process.argv.slice(2),
29
+ { out = null, env = process.env, deps = null } = {},
30
+ ) {
31
+ const opts = parseUpdateArgs(argv);
32
+ const lang = opts.lang || detectReportLang();
33
+ const t = getCatalog(lang).cli.update;
34
+ const write = out || ((s) => process.stdout.write(s));
35
+ const flow = deps || updateFlow;
36
+
37
+ if (opts.help) {
38
+ write(t.help + '\n');
39
+ return;
40
+ }
41
+
42
+ const info = flow.readPackageInfo();
43
+ const install = flow.detectInstall(env);
44
+ const current = info.version;
45
+
46
+ if (!opts.json) write(` ${t.checking}\n`);
47
+ const latest = flow.fetchLatestVersion(info);
48
+
49
+ if (opts.json) {
50
+ const updateAvailable =
51
+ latest.ok && current ? flow.compareVersions(current, latest.version) < 0 : null;
52
+ write(
53
+ `${JSON.stringify({
54
+ package: info.name,
55
+ current: current || null,
56
+ latest: latest.ok ? latest.version : null,
57
+ install: install.kind,
58
+ updateAvailable,
59
+ reason: latest.ok ? null : latest.reason,
60
+ })}\n`,
61
+ );
62
+ if (!latest.ok) process.exitCode = 1;
63
+ return;
64
+ }
65
+
66
+ if (current) write(` ${t.current(current)}\n`);
67
+
68
+ if (!latest.ok) {
69
+ write(` ${t.latestUnknown(latest.reason)}\n`);
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+
74
+ if (!current || flow.compareVersions(current, latest.version) >= 0) {
75
+ write(` ${t.upToDate(latest.version)}\n`);
76
+ return;
77
+ }
78
+
79
+ write(` ${t.available(current, latest.version)}\n`);
80
+
81
+ // `--check`: report only, never install.
82
+ if (opts.check) {
83
+ write(` ${t.checkHint}\n`);
84
+ return;
85
+ }
86
+
87
+ const plan = flow.buildUpdatePlan(install, info, env);
88
+ if (!plan) {
89
+ if (install.kind === 'source') write(` ${t.sourceCheckout}\n`);
90
+ else write(` ${t.unknownInstall(flow.manualCommand(info))}\n`);
91
+ process.exitCode = 1;
92
+ return;
93
+ }
94
+
95
+ write(` ${t.detected(plan.label)}\n`);
96
+ write(` ${t.updating(info.name, current, latest.version)}\n\n`);
97
+
98
+ const result = flow.runInstall(plan, env);
99
+ if (result.ok) {
100
+ write(`\n ${t.success(latest.version)}\n ${t.reRun}\n`);
101
+ return;
102
+ }
103
+ write(`\n ${t.failInstall(result.reason)}\n ${t.failHint(flow.manualCommand(info))}\n`);
104
+ process.exitCode = 1;
105
+ }
106
+
107
+ module.exports = { run, parseUpdateArgs };
108
+
109
+ if (require.main === module) {
110
+ run();
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakers-cli-beta",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "description": "Shakers AI usage & skills evaluator: a local-first CLI (`shakers` binary) plus a local MCP server. Commands: ai-usage (scan + classify your AI-tool setup, T0-T7), certify (certify Skills from your code), share (branded LinkedIn card).",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { scrubSecrets } = require('./agent-synthesis');
6
+ const { loadAuthSession } = require('./auth-session-store');
7
+ const { getCertsBase } = require('./config');
8
+ const { postJsonWithTimeout } = require('./backend-request');
9
+
10
+ // Fire-and-forget: short timeout, every error swallowed, so a logging failure never blocks or fails a command.
11
+ const AUDIT_TIMEOUT_MS = 1500;
12
+
13
+ // Capped at 200 chars AFTER scrubbing (mirrors MAX_COMMAND_LEN in session-scan.js) so a secret straddling the cut cannot leak.
14
+ const MAX_ARGS_LEN = 200;
15
+
16
+ function cliVersion() {
17
+ try {
18
+ return require(path.join(__dirname, '..', 'package.json')).version || null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ // Scrub THEN cap: scrubSecrets can GROW text, so the cap is measured on the scrubbed string that goes on the wire.
25
+ function redactArgs(argv) {
26
+ if (!Array.isArray(argv) || argv.length === 0) return null;
27
+ const scrubbed = scrubSecrets(argv.join(' ')).trim().slice(0, MAX_ARGS_LEN);
28
+ return scrubbed || null;
29
+ }
30
+
31
+ // Anonymous runs carry only a best-effort machine hint; authenticated runs identify via the Bearer instead.
32
+ function machineHint() {
33
+ let osUser = null;
34
+ try {
35
+ const info = os.userInfo();
36
+ osUser = info && typeof info.username === 'string' ? info.username : null;
37
+ } catch {
38
+ osUser = null;
39
+ }
40
+ let hostname = null;
41
+ try {
42
+ hostname = os.hostname() || null;
43
+ } catch {
44
+ hostname = null;
45
+ }
46
+ return { osUser, hostname };
47
+ }
48
+
49
+ // Builds the record + headers; returns null when there is no endpoint to post to.
50
+ function buildAudit(command, argv, env) {
51
+ let base;
52
+ try {
53
+ base = getCertsBase(env);
54
+ } catch {
55
+ base = null;
56
+ }
57
+ if (!base) return null;
58
+
59
+ const session = loadAuthSession(env);
60
+ const token =
61
+ session && (session.accessToken || session.hubAccessToken)
62
+ ? session.accessToken || session.hubAccessToken
63
+ : null;
64
+
65
+ const body = {
66
+ command: String(command || 'unknown').slice(0, 100),
67
+ args: redactArgs(argv),
68
+ cliVersion: cliVersion(),
69
+ clientTimestamp: new Date().toISOString(),
70
+ };
71
+
72
+ // Machine hint only when anonymous; identity comes from the token otherwise.
73
+ if (!token) {
74
+ const { osUser, hostname } = machineHint();
75
+ body.osUser = osUser;
76
+ body.hostname = hostname;
77
+ }
78
+
79
+ const headers = token ? { Authorization: `Bearer ${token}` } : null;
80
+ const endpoint = `${base.replace(/\/+$/, '')}/cli/command-log`;
81
+ return { endpoint, body, headers };
82
+ }
83
+
84
+ // Emits ONE fire-and-forget audit record; never throws or rejects. Returns the `.catch`-guarded promise so tests can await it (production does not).
85
+ function emitCommandAudit({ command, argv = [], env = process.env } = {}) {
86
+ try {
87
+ const audit = buildAudit(command, argv, env);
88
+ if (!audit) return Promise.resolve();
89
+ return postJsonWithTimeout(
90
+ audit.endpoint,
91
+ audit.body,
92
+ AUDIT_TIMEOUT_MS,
93
+ 'POST',
94
+ null,
95
+ audit.headers,
96
+ ).then(
97
+ () => {},
98
+ () => {},
99
+ );
100
+ } catch {
101
+ return Promise.resolve();
102
+ }
103
+ }
104
+
105
+ module.exports = { emitCommandAudit, redactArgs, buildAudit };
package/src/i18n.js CHANGED
@@ -487,6 +487,39 @@ const catalogs = {
487
487
  yes: 'Sí, iniciar sesión',
488
488
  no: 'No, continuar sin cuenta',
489
489
  },
490
+ // `update` command: actualiza el CLI a la última versión publicada (solo CLI, no MCP).
491
+ update: {
492
+ help:
493
+ 'update — actualiza el CLI de Shakers a la última versión publicada.\n\n'
494
+ + ' Uso: shakers update [--check]\n\n'
495
+ + ' (sin flag) Comprueba la última versión y, si hay una más nueva, la instala\n'
496
+ + ' por el mismo método con el que se instaló este CLI.\n'
497
+ + ' --check Solo informa de tu versión frente a la última publicada; no instala.\n'
498
+ + ' --json Salida JSON (paquete, versión actual/última, si hay actualización).\n',
499
+ checking: 'Comprobando actualizaciones…',
500
+ current: (v) => `Versión instalada: ${v}`,
501
+ upToDate: (v) => `Ya tienes la última versión (${v}).`,
502
+ available: (from, to) => `Hay una actualización disponible: ${from} → ${to}`,
503
+ checkHint: 'Ejecuta `shakers update` (sin --check) para instalarla.',
504
+ detected: (label) => `Instalación detectada: ${label}`,
505
+ updating: (name, from, to) => `Actualizando ${name}: ${from} → ${to}…`,
506
+ success: (v) => `Actualizado a la versión ${v}.`,
507
+ reRun: 'Vuelve a ejecutar tu comando para usar la nueva versión.',
508
+ latestUnknown: (reason) =>
509
+ `No se pudo determinar la última versión (${reason}). Revisa tu conexión y el acceso al registro.`,
510
+ failInstall: (reason) => `La actualización falló (${reason}).`,
511
+ failHint: (cmd) =>
512
+ `Revisa tu conexión y permisos y vuelve a intentarlo, o actualiza a mano:\n ${cmd}`,
513
+ sourceCheckout:
514
+ 'Este CLI se ejecuta desde un checkout de código — actualízalo con `git pull` en ese repositorio.',
515
+ unknownInstall: (cmd) =>
516
+ `No se pudo detectar cómo se instaló este CLI. Actualiza a mano:\n ${cmd}`,
517
+ },
518
+ // Aviso pasivo "hay una versión nueva" al arrancar cualquier comando (lectura de caché, sin red en el camino caliente).
519
+ updateNotifier: {
520
+ available: (from, to) => `Hay una versión nueva del CLI de Shakers: ${from} → ${to}`,
521
+ howTo: 'Ejecuta `shakers update` para actualizar.',
522
+ },
490
523
  // `report` command (ADR-016): genera y ABRE el informe HTML completo y compartible de este proyecto (uso de IA + Skills certificadas).
491
524
  report: {
492
525
  help: 'report — genera y abre el informe HTML completo de este proyecto (uso de IA + Skills certificadas) para compartir con tu equipo.\n'
@@ -982,6 +1015,7 @@ const catalogs = {
982
1015
  noTerminal: 'No hay una terminal donde preguntarte, así que no he tocado nada. Vuelve a ejecutar el instalador en tu terminal para conectarlo.',
983
1016
  restart: 'Reinicia las apps que estén abiertas para que carguen Shakers.',
984
1017
  noneFound: 'No he encontrado ninguna app de IA compatible en esta máquina.',
1018
+ failedAll: 'No he podido conectar Shakers a tus apps de IA. Vuelve a ejecutar el instalador para reintentarlo.',
985
1019
  },
986
1020
  rate: {
987
1021
  help: 'rate — ver o fijar tu tarifa por proyecto.\n\nUso:\n rate [--lang es|en] [--json] ver tu tarifa\n rate --set [--lang es|en] fijarla (interactivo, con confirmación)\n\nRequiere una sesión activa (shakers login).',
@@ -2122,6 +2156,39 @@ const catalogs = {
2122
2156
  yes: 'Yes, sign in',
2123
2157
  no: 'No, continue without an account',
2124
2158
  },
2159
+ // `update` command: updates the CLI to the latest published version (CLI-only, not MCP).
2160
+ update: {
2161
+ help:
2162
+ 'update — update the Shakers CLI to the latest published version.\n\n'
2163
+ + ' Usage: shakers update [--check]\n\n'
2164
+ + ' (no flag) Check the latest version and, if a newer one exists, install it\n'
2165
+ + ' the same way this CLI was installed.\n'
2166
+ + ' --check Only report your version vs the latest published; do not install.\n'
2167
+ + ' --json JSON output (package, current/latest version, whether an update exists).\n',
2168
+ checking: 'Checking for updates…',
2169
+ current: (v) => `Installed version: ${v}`,
2170
+ upToDate: (v) => `You are on the latest version (${v}).`,
2171
+ available: (from, to) => `An update is available: ${from} → ${to}`,
2172
+ checkHint: 'Run `shakers update` (without --check) to install it.',
2173
+ detected: (label) => `Detected install: ${label}`,
2174
+ updating: (name, from, to) => `Updating ${name}: ${from} → ${to}…`,
2175
+ success: (v) => `Updated to version ${v}.`,
2176
+ reRun: 'Re-run your command to use the new version.',
2177
+ latestUnknown: (reason) =>
2178
+ `Could not determine the latest version (${reason}). Check your network and registry access.`,
2179
+ failInstall: (reason) => `The update failed (${reason}).`,
2180
+ failHint: (cmd) =>
2181
+ `Check your network and permissions and try again, or update manually:\n ${cmd}`,
2182
+ sourceCheckout:
2183
+ 'This CLI is running from a source checkout — update it with `git pull` in that repository.',
2184
+ unknownInstall: (cmd) =>
2185
+ `Could not detect how this CLI was installed. Update manually:\n ${cmd}`,
2186
+ },
2187
+ // Passive "a newer version exists" notice printed when any command starts (cache read, no network on the hot path).
2188
+ updateNotifier: {
2189
+ available: (from, to) => `A newer Shakers CLI is available: ${from} → ${to}`,
2190
+ howTo: 'Run `shakers update` to upgrade.',
2191
+ },
2125
2192
  // `report` command (ADR-016): builds and OPENS the full, shareable HTML report for this project (AI usage + certified Skills).
2126
2193
  report: {
2127
2194
  help: 'report — build and open the full HTML report for this project (AI usage + certified Skills) to share with your team.\n'
@@ -2610,6 +2677,7 @@ const catalogs = {
2610
2677
  noTerminal: 'There is no terminal to ask you on, so nothing was touched. Run the installer again in your terminal to connect it.',
2611
2678
  restart: 'Restart any of these apps that is open so it loads Shakers.',
2612
2679
  noneFound: 'Found no supported AI app on this machine.',
2680
+ failedAll: 'Could not connect Shakers to your AI apps. Re-run this installer to retry.',
2613
2681
  },
2614
2682
  rate: {
2615
2683
  help: 'rate — view or set your per-project rate.\n\nUsage:\n rate [--lang es|en] [--json] view your rate\n rate --set [--lang es|en] set it (interactive, with confirmation)\n\nRequires an active session (shakers login).',
@@ -266,7 +266,8 @@ async function runMcpInstall({ out = (s) => process.stdout.write(s), lang = null
266
266
  out(` ${c.clients[r.client]}: ${c.status[r.status]}\n`);
267
267
  if (r.removedLegacy) out(` ${c.removedLegacy}\n`);
268
268
  }
269
- out(`\n ${c.restart}\n\n`);
269
+ const touched = results.some((r) => r.status === 'configured' || r.status === 'unchanged');
270
+ out(`\n ${touched ? c.restart : c.failedAll}\n\n`);
270
271
  if (results.some((r) => r.status === 'failed' || r.status === 'invalid-json')) process.exitCode = 1;
271
272
  return { ok: true, results };
272
273
  }
@@ -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
+ };