@thinger-io/cli-core 0.5.0 → 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 +25 -0
- package/README.md +6 -0
- package/lib/auth.js +25 -1
- package/lib/cli.js +18 -1
- package/lib/mcp/catalog.js +1 -1
- package/lib/mcp/server.js +29 -2
- package/lib/mcp/tools-resource.js +5 -3
- package/lib/update-check.js +87 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
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
|
+
|
|
16
|
+
## [0.5.1] - 2026-10-31
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `host` action `update_settings` says what the server does: it merges the
|
|
21
|
+
body into the host's custom settings (keys left out stay, `null` removes
|
|
22
|
+
one). Its confirmation plan no longer claims it removes the keys left
|
|
23
|
+
out of the body.
|
|
24
|
+
- The browser login on a server that does not know the CLI's OAuth client
|
|
25
|
+
(older than Thinger 7.5.0, or the client was removed) says so and offers
|
|
26
|
+
username and password or a token instead of failing.
|
|
27
|
+
|
|
3
28
|
## [0.5.0] - 2026-10-21
|
|
4
29
|
|
|
5
30
|
One grammar for every command, noun first:
|
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/auth.js
CHANGED
|
@@ -110,7 +110,22 @@ export async function authenticate(opts = {}) {
|
|
|
110
110
|
if (method === 'userpass') {
|
|
111
111
|
await authenticateWithUserPass(opts);
|
|
112
112
|
} else if (method === 'oauth') {
|
|
113
|
-
await authenticateWithOAuth(opts, existing)
|
|
113
|
+
await authenticateWithOAuth(opts, existing).catch(async (error) => {
|
|
114
|
+
// A server older than the CLI's built-in OAuth client, or
|
|
115
|
+
// one whose admin removed it: sign in another way.
|
|
116
|
+
if (error.code !== 'oauth_client_unknown' || !process.stdin.isTTY) throw error;
|
|
117
|
+
console.log(warning(error.message));
|
|
118
|
+
const fallback = await ask(select, {
|
|
119
|
+
message: 'Sign in another way:',
|
|
120
|
+
choices: [
|
|
121
|
+
{ name: 'Username and password', value: 'userpass' },
|
|
122
|
+
{ name: 'Token', value: 'token' },
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
return fallback === 'userpass'
|
|
126
|
+
? authenticateWithUserPass(opts)
|
|
127
|
+
: authenticateWithToken(opts);
|
|
128
|
+
});
|
|
114
129
|
} else {
|
|
115
130
|
await authenticateWithToken(opts);
|
|
116
131
|
}
|
|
@@ -391,6 +406,15 @@ async function authenticateWithOAuth(opts, existing) {
|
|
|
391
406
|
spinner.succeed('OAuth User Code received');
|
|
392
407
|
} catch (error) {
|
|
393
408
|
spinner.fail('OAuth device-code request failed');
|
|
409
|
+
const status = error.response?.status;
|
|
410
|
+
if (status === 401 || status === 404) {
|
|
411
|
+
throw Object.assign(
|
|
412
|
+
new Error(
|
|
413
|
+
`The server does not know the OAuth client ${client_id} (Thinger 7.5.0 and later have it built in). Sign in with a token or username and password instead.`,
|
|
414
|
+
),
|
|
415
|
+
{ code: 'oauth_client_unknown' },
|
|
416
|
+
);
|
|
417
|
+
}
|
|
394
418
|
throw error;
|
|
395
419
|
}
|
|
396
420
|
|
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/catalog.js
CHANGED
|
@@ -665,7 +665,7 @@ export const CATALOG = {
|
|
|
665
665
|
method: 'put',
|
|
666
666
|
path: 'settings',
|
|
667
667
|
description:
|
|
668
|
-
'
|
|
668
|
+
'Patch the host settings with `body` (JSON merge: keys left out stay, null removes one); read them first. Confirm with the user first.',
|
|
669
669
|
},
|
|
670
670
|
},
|
|
671
671
|
},
|
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
|
-
...(
|
|
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
|
|
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
|
|
@@ -1493,11 +1493,13 @@ export function resourceTools({
|
|
|
1493
1493
|
.then((r) => r.data)
|
|
1494
1494
|
.catch(() => undefined);
|
|
1495
1495
|
if (isRecord(now) && isRecord(body)) {
|
|
1496
|
+
// The server merges the body (JSON merge-patch): keys left
|
|
1497
|
+
// out stay, a null removes one.
|
|
1498
|
+
const removed = Object.keys(body).filter((k) => body[k] === null && k in now);
|
|
1496
1499
|
const changed = Object.keys(body).filter(
|
|
1497
|
-
(k) => JSON.stringify(now[k]) !== JSON.stringify(body[k]),
|
|
1500
|
+
(k) => body[k] !== null && JSON.stringify(now[k]) !== JSON.stringify(body[k]),
|
|
1498
1501
|
);
|
|
1499
|
-
|
|
1500
|
-
return `It would replace the settings of ${target}: changes ${some(changed)}; removes ${some(dropped)} (left out of the body).`;
|
|
1502
|
+
return `It would patch the settings of ${target}: changes ${some(changed)}; removes ${some(removed)}; the rest stays.`;
|
|
1501
1503
|
}
|
|
1502
1504
|
}
|
|
1503
1505
|
if (action === 'test_notification') {
|
|
@@ -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