@thinger-io/cli-core 0.5.1 → 0.5.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.2] - 2026-10-31
4
+
5
+ ### Added
6
+
7
+ - The CLI says when a newer release of it is on npm: once a day it asks the
8
+ registry for the latest version of the identity's `npmPackage` (cached in
9
+ the cache folder) and, after a command run in a terminal, prints one line
10
+ with the update command. The MCP server tells the agent instead, in its
11
+ instructions when the cache already knows and otherwise with the first
12
+ answer of the session, so the agent can suggest the update to the user.
13
+ Off in CI, with JSON output, and with `<PREFIX>_NO_UPDATE_CHECK=1` or
14
+ `NO_UPDATE_NOTIFIER=1`.
15
+
3
16
  ## [0.5.1] - 2026-10-31
4
17
 
5
18
  ### Fixed
package/README.md CHANGED
@@ -48,6 +48,12 @@ It holds everything that is not specific to a product:
48
48
  reads.
49
49
  - **OpenAPI** (`openapi`) — the server's own API description, cached per
50
50
  server and version, with a bundled copy as fallback.
51
+ - **Update check** (`update-check`) — once a day, the latest release of the
52
+ CLI's npm package (`npmPackage` in the identity). After a command run in
53
+ a terminal, one line says when a newer one is out; the MCP server tells
54
+ the agent, in its instructions or with the first answer of the session,
55
+ so it can suggest the update. Off in CI and with `<PREFIX>_NO_UPDATE_CHECK=1`
56
+ (or `NO_UPDATE_NOTIFIER=1`).
51
57
 
52
58
  ## Identity
53
59
 
package/lib/cli.js CHANGED
@@ -3,7 +3,9 @@ import { Command } from 'commander';
3
3
  import { setActiveProfile } from './config.js';
4
4
  import { env, identity } from './identity.js';
5
5
 
6
- import { classifyError, detectJsonModeFromArgv } from './output.js';
6
+ import { classifyError, detectJsonModeFromArgv, isJsonMode } from './output.js';
7
+ import { warning } from './format.js';
8
+ import { latestVersion, updateNotice } from './update-check.js';
7
9
  import { startMCPServer } from './mcp/server.js';
8
10
  import { registerAuthCommands } from './commands/auth.js';
9
11
  import { registerProfileCommands } from './commands/profile.js';
@@ -171,4 +173,19 @@ export async function runCli({ argv = process.argv, onNoCommand, ...opts }) {
171
173
  return;
172
174
  }
173
175
  await program.parseAsync(argv);
176
+ await notifyUpdate(opts.version, argv);
177
+ }
178
+
179
+ /**
180
+ * After a command run in a terminal, one line on stderr when a newer
181
+ * release is on npm. Not for JSON output, nor for `mcp`, whose server is
182
+ * still running (it tells the agent instead).
183
+ *
184
+ * @param {string} version
185
+ * @param {string[]} argv
186
+ */
187
+ async function notifyUpdate(version, argv) {
188
+ if (!process.stderr.isTTY || isJsonMode() || argv.slice(2).includes('mcp')) return;
189
+ const notice = updateNotice(version, await latestVersion());
190
+ if (notice) console.error(warning(notice));
174
191
  }
package/lib/mcp/server.js CHANGED
@@ -12,6 +12,7 @@ import { identity } from '../identity.js';
12
12
  import { buildTools, listResources, readResource } from './registry.js';
13
13
  import { withProfile } from './helpers.js';
14
14
  import { defineCliDocs } from '../docs.js';
15
+ import { cachedLatest, latestVersion, updateNotice } from '../update-check.js';
15
16
 
16
17
  /** What a tool declares that the protocol lists. */
17
18
  const LISTED = ['name', 'title', 'description', 'inputSchema', 'annotations'];
@@ -59,6 +60,18 @@ export async function startMCPServer({
59
60
  readOnly,
60
61
  });
61
62
 
63
+ // A newer release of the CLI: the agent hears it in the instructions
64
+ // when the cache already knows, otherwise with the first answer of the
65
+ // session (the registry is asked from now, in the background).
66
+ const latest = latestVersion();
67
+ const forAgent = (notice) =>
68
+ `${notice}. Tell the user and suggest the update; the MCP server runs the new version once the client restarts it.`;
69
+ const known = updateNotice(version, cachedLatest().latest);
70
+ let told = !!known;
71
+ const serverInstructions = [instructions, known && forAgent(known)]
72
+ .filter(Boolean)
73
+ .join('\n\n');
74
+
62
75
  const server = new Server(
63
76
  {
64
77
  name: mcp.serverName,
@@ -66,10 +79,22 @@ export async function startMCPServer({
66
79
  },
67
80
  {
68
81
  capabilities: { tools: {}, resources: {} },
69
- ...(instructions ? { instructions } : {}),
82
+ ...(serverInstructions ? { instructions: serverInstructions } : {}),
70
83
  },
71
84
  );
72
85
 
86
+ /** @param {any} result */
87
+ const withUpdateNote = async (result) => {
88
+ if (told) return result;
89
+ told = true;
90
+ const notice = updateNotice(version, await latest);
91
+ if (!notice || !Array.isArray(result?.content)) return result;
92
+ return {
93
+ ...result,
94
+ content: [...result.content, { type: 'text', text: forAgent(notice) }],
95
+ };
96
+ };
97
+
73
98
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
74
99
  tools: tools.map((t) =>
75
100
  Object.fromEntries(LISTED.filter((k) => t[k] !== undefined).map((k) => [k, t[k]])),
@@ -136,7 +161,9 @@ export async function startMCPServer({
136
161
  }
137
162
  const extra = server.getClientCapabilities()?.elicitation ? { elicit } : {};
138
163
  try {
139
- return await withProfile(args.profile, () => tool.handler(args, extra));
164
+ return await withUpdateNote(
165
+ await withProfile(args.profile, () => tool.handler(args, extra)),
166
+ );
140
167
  } catch (error) {
141
168
  // Surface the same {message, code} pair the CLI emits in JSON
142
169
  // mode so MCP clients can pattern-match on `[code]` instead of
@@ -0,0 +1,87 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { cacheDir } from './config.js';
5
+ import { compareVersions } from './dashboard-sdk.js';
6
+ import { env, identity } from './identity.js';
7
+
8
+ /**
9
+ * Whether a newer release of the CLI is on npm, so the CLI and its MCP
10
+ * server can tell the user (and the agent) to update. The answer of the
11
+ * registry is kept for a day in the cache folder; everything here is
12
+ * advice: what cannot be read gives no notice.
13
+ */
14
+
15
+ const REGISTRY = 'https://registry.npmjs.org';
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ const TIMEOUT_MS = 1500;
18
+ const CACHE_FILE = 'latest-version.json';
19
+
20
+ /**
21
+ * Off without an npm package to ask about, in CI and tests, and when the
22
+ * user turns it off (`<PREFIX>_NO_UPDATE_CHECK=1` or the common
23
+ * `NO_UPDATE_NOTIFIER`).
24
+ */
25
+ export function updateCheckEnabled() {
26
+ if (!identity().npmPackage) return false;
27
+ const off = (v) => v !== undefined && v !== '' && !/^(0|false|no)$/i.test(v);
28
+ return !(
29
+ off(env('NO_UPDATE_CHECK')) ||
30
+ off(process.env.NO_UPDATE_NOTIFIER) ||
31
+ off(process.env.CI) ||
32
+ process.env.NODE_TEST_CONTEXT
33
+ );
34
+ }
35
+
36
+ function cachePath() {
37
+ return path.join(cacheDir(), CACHE_FILE);
38
+ }
39
+
40
+ /** The latest version the cache knows, and whether it is less than a day old. */
41
+ export function cachedLatest() {
42
+ try {
43
+ const { latest, checkedAt } = JSON.parse(fs.readFileSync(cachePath(), 'utf8'));
44
+ if (typeof latest !== 'string') return {};
45
+ return { latest, fresh: Date.now() - Number(checkedAt) < DAY_MS };
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The latest version on npm: from the cache when it is fresh, otherwise
53
+ * asked to the registry (and cached). Undefined when it cannot be known.
54
+ *
55
+ * @returns {Promise<string | undefined>}
56
+ */
57
+ export async function latestVersion() {
58
+ if (!updateCheckEnabled()) return undefined;
59
+ const cached = cachedLatest();
60
+ if (cached.fresh) return cached.latest;
61
+ try {
62
+ const res = await fetch(`${REGISTRY}/${identity().npmPackage}/latest`, {
63
+ signal: AbortSignal.timeout(TIMEOUT_MS),
64
+ });
65
+ if (!res.ok) return cached.latest;
66
+ const { version } = /** @type {{ version?: unknown }} */ (await res.json());
67
+ if (typeof version !== 'string') return cached.latest;
68
+ fs.writeFileSync(cachePath(), JSON.stringify({ latest: version, checkedAt: Date.now() }));
69
+ return version;
70
+ } catch {
71
+ return cached.latest;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The line that tells the user a newer release is out, or undefined when
77
+ * `version` is the latest (or newer: a development build).
78
+ *
79
+ * @param {string} version The running CLI's.
80
+ * @param {string | undefined} latest
81
+ */
82
+ export function updateNotice(version, latest) {
83
+ if (!latest || !version || version === 'unknown') return undefined;
84
+ if (compareVersions(latest, version) <= 0) return undefined;
85
+ const { name, npmPackage } = identity();
86
+ return `${name} ${latest} is available (this is ${version}). Update with: npm i -g ${npmPackage}`;
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinger-io/cli-core",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Shared core of the Thinger.io CLIs and their MCP servers: API client, auth, profiles, generic commands and MCP tools",
5
5
  "type": "module",
6
6
  "exports": {