@tokenoftrust/cli 1.1.0 → 1.2.1
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/tot.mjs +4 -0
- package/package.json +1 -1
- package/src/auth.mjs +5 -1
- package/src/commands/whoami.mjs +6 -1
- package/src/mcp.mjs +20 -2
- package/src/update-check-worker.mjs +12 -0
- package/src/update-check.mjs +217 -0
package/bin/tot.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { readFileSync } from "node:fs";
|
|
|
31
31
|
import { detectContext } from "../src/context.mjs";
|
|
32
32
|
import { printError } from "../src/errors.mjs";
|
|
33
33
|
import { recordActivity, redactArgs } from "../src/activity-log.mjs";
|
|
34
|
+
import { maybeNotifyUpdate } from "../src/update-check.mjs";
|
|
34
35
|
|
|
35
36
|
const BUILD_ORDER = ["checkout", "validate", "dev", "submit"];
|
|
36
37
|
|
|
@@ -166,6 +167,9 @@ async function main() {
|
|
|
166
167
|
ms: Date.now() - startedAt,
|
|
167
168
|
...(errMsg ? { err: String(errMsg).slice(0, 200) } : {}),
|
|
168
169
|
});
|
|
170
|
+
// Nudge if a newer/unsupported version exists (drawn from cache — instant),
|
|
171
|
+
// and kick a detached registry refresh if stale. Never throws, never blocks.
|
|
172
|
+
maybeNotifyUpdate(VERSION);
|
|
169
173
|
}
|
|
170
174
|
}
|
|
171
175
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Token of Trust developer CLI — check out a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/auth.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
defaultCredentialsPath, readCredentials, writeCredentials, isExpired,
|
|
28
28
|
} from "./token-store.mjs";
|
|
29
29
|
import { refreshAccessToken, credentialsFromToken } from "./oauth.mjs";
|
|
30
|
+
import { recordServerPolicy } from "./update-check.mjs";
|
|
30
31
|
|
|
31
32
|
/** Thrown when no provider can authenticate — carries actionable guidance. */
|
|
32
33
|
export class AuthUnavailableError extends Error {
|
|
@@ -60,11 +61,14 @@ export async function resolveSession(client, opts = {}) {
|
|
|
60
61
|
{ hint: "source apps/storefront/lib/resolve-tot-credentials.sh (from a storefront checkout), or unset --identity to use developer sign-in." },
|
|
61
62
|
);
|
|
62
63
|
}
|
|
63
|
-
await client.callTool("credential_validate", {
|
|
64
|
+
const validated = await client.callTool("credential_validate", {
|
|
64
65
|
totApiKey: env.TOT_API_KEY,
|
|
65
66
|
totSecretKey: env.TOT_SECRET_KEY,
|
|
66
67
|
appDomain: env.TOT_APP_DOMAIN,
|
|
67
68
|
});
|
|
69
|
+
// Update-awareness Layer 2: the MCP may attach a version-support policy to the
|
|
70
|
+
// authed response (wire contract: `cliPolicy`). Safe no-op when absent.
|
|
71
|
+
recordServerPolicy(validated?.cliPolicy, env);
|
|
68
72
|
return { identity: "operator", appDomain: env.TOT_APP_DOMAIN };
|
|
69
73
|
}
|
|
70
74
|
|
package/src/commands/whoami.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { defaultCredentialsPath, readCredentials, isExpired } from "../token-sto
|
|
|
13
13
|
import { hasOperatorCreds, resolveDeveloperSession } from "../auth.mjs";
|
|
14
14
|
import { createMcpClient } from "../mcp.mjs";
|
|
15
15
|
import { normalizeStores } from "./checkout.mjs";
|
|
16
|
+
import { recordServerPolicy } from "../update-check.mjs";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Pure summary of the cached session — no network. Returned for both display and
|
|
@@ -56,7 +57,11 @@ export async function run(argv, _ctx) {
|
|
|
56
57
|
const client = createMcpClient(status.mcpUrl || env.MCP_BASE_URL || env.TOT_MCP_URL || "https://mcp.tokenoftrust.com");
|
|
57
58
|
await client.initialize();
|
|
58
59
|
await resolveDeveloperSession(client, env);
|
|
59
|
-
const
|
|
60
|
+
const listResp = await client.callTool("client_list", {});
|
|
61
|
+
// Update-awareness Layer 2: an authed response may carry a version-support
|
|
62
|
+
// policy (wire contract: `cliPolicy`). Safe no-op when absent.
|
|
63
|
+
recordServerPolicy(listResp?.cliPolicy, env);
|
|
64
|
+
const stores = normalizeStores(listResp);
|
|
60
65
|
if (stores.length) {
|
|
61
66
|
console.log(` stores you can build on: ${stores.map((s) => s.id).join(", ")}`);
|
|
62
67
|
} else {
|
package/src/mcp.mjs
CHANGED
|
@@ -7,10 +7,26 @@
|
|
|
7
7
|
* answer as JSON or as an SSE stream, with the session id carried in the
|
|
8
8
|
* `Mcp-Session-Id` header across calls. Dependency-free (global fetch, Node 20+).
|
|
9
9
|
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
// The CLI's REAL version for the MCP handshake — the transmission channel the
|
|
13
|
+
// server-side support policy (update-awareness Layer 2) decides against. Read from
|
|
14
|
+
// our own package.json (same pattern as bin/tot.mjs); best-effort, never throws.
|
|
15
|
+
export const CLI_VERSION = (() => {
|
|
16
|
+
try {
|
|
17
|
+
return (
|
|
18
|
+
JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ||
|
|
19
|
+
"0.0.0"
|
|
20
|
+
);
|
|
21
|
+
} catch {
|
|
22
|
+
return "0.0.0";
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
10
25
|
|
|
11
26
|
/**
|
|
12
27
|
* @param {string} baseUrl - MCP base URL; `/mcp` is appended if absent.
|
|
13
|
-
* @param {{ token?: string }} [opts] - optional developer OAuth
|
|
28
|
+
* @param {{ token?: string, clientVersion?: string }} [opts] - optional developer OAuth
|
|
29
|
+
* bearer to attach; `clientVersion` overrides the handshake version (tests).
|
|
14
30
|
* @returns a small client: { mcpUrl, initialize, callRaw, callTool, sessionId(), setToken }
|
|
15
31
|
*/
|
|
16
32
|
export function createMcpClient(baseUrl, opts = {}) {
|
|
@@ -86,7 +102,9 @@ export function createMcpClient(baseUrl, opts = {}) {
|
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
/** Complete the MCP handshake. Call once before any tool call. */
|
|
89
|
-
async function initialize(
|
|
105
|
+
async function initialize(
|
|
106
|
+
clientInfo = { name: "tot-cli", version: opts.clientVersion || CLI_VERSION },
|
|
107
|
+
) {
|
|
90
108
|
await callRaw("initialize", {
|
|
91
109
|
protocolVersion: "2025-06-18",
|
|
92
110
|
capabilities: {},
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detached worker for the update check: fetch the `latest` dist-tag from the npm
|
|
3
|
+
* registry and write it to `~/.tot/update-check.json`. Spawned unref'd by
|
|
4
|
+
* update-check.mjs's refreshInBackground() so the registry round-trip never adds
|
|
5
|
+
* latency to (or holds open) the user's actual `tot` command.
|
|
6
|
+
*
|
|
7
|
+
* Runs standalone (`node src/update-check-worker.mjs`), inheriting the parent env
|
|
8
|
+
* (TOT_HOME / TOT_NPM_REGISTRY). Best-effort: any failure just exits quietly.
|
|
9
|
+
*/
|
|
10
|
+
import { runRefresh } from "./update-check.mjs";
|
|
11
|
+
|
|
12
|
+
runRefresh().finally(() => process.exit(0));
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update awareness for `tot` — so an installed CLI can recognize when a newer
|
|
3
|
+
* (or a no-longer-supported) version exists and nudge the developer to upgrade.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS SHIPS NOW, not later: whatever nudge logic a published version carries
|
|
6
|
+
* is the FLOOR for that install. A client on today's version can never retroactively
|
|
7
|
+
* learn to nag — so the mechanism has to be present before the install base grows.
|
|
8
|
+
*
|
|
9
|
+
* Two layers, both baked in here:
|
|
10
|
+
* • Layer 1 — client compare: fetch our own `latest` dist-tag from the npm registry
|
|
11
|
+
* and compare to the running version. Answers "is there something newer?"
|
|
12
|
+
* • Layer 2 — server policy: an authed command that talks to the MCP can hand us a
|
|
13
|
+
* `{ minSupported, recommended, message }` policy (via recordServerPolicy). Answers
|
|
14
|
+
* "is this version still SUPPORTED?" — a decision only the server can make. Because
|
|
15
|
+
* the client just renders whatever policy it was last handed, we can raise a hard
|
|
16
|
+
* floor years from now and even today's build will honor it. That render-what-the-
|
|
17
|
+
* server-says hook is the part that must exist now; the policy stays editable forever.
|
|
18
|
+
*
|
|
19
|
+
* Contract (like activity-log / token-store):
|
|
20
|
+
* • NEVER throws and NEVER blocks a command. The nudge is drawn from the LAST cached
|
|
21
|
+
* result (instant, no network on the hot path); the registry refresh happens in a
|
|
22
|
+
* DETACHED, unref'd child so it can't add latency or hold the process open.
|
|
23
|
+
* • Cached in `~/.tot/update-check.json`; refreshed at most once per CHECK_INTERVAL_MS.
|
|
24
|
+
* • Suppressed when stderr isn't a TTY, in CI, or when TOT_NO_UPDATE_CHECK /
|
|
25
|
+
* NO_UPDATE_NOTIFIER is set. `TOT_HOME` overrides the home dir (tests).
|
|
26
|
+
*/
|
|
27
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync } from "node:fs";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { join, dirname } from "node:path";
|
|
30
|
+
import { spawn } from "node:child_process";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
|
|
33
|
+
/** Refresh the registry check at most this often. */
|
|
34
|
+
export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
35
|
+
|
|
36
|
+
const PKG = "@tokenoftrust/cli";
|
|
37
|
+
const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
38
|
+
const FETCH_TIMEOUT_MS = 3000;
|
|
39
|
+
|
|
40
|
+
/** Absolute path to the update-check cache for this environment. */
|
|
41
|
+
export function updateCachePath(env = process.env) {
|
|
42
|
+
const home = env.TOT_HOME || homedir();
|
|
43
|
+
return join(home, ".tot", "update-check.json");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readCache(env) {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(updateCachePath(env), "utf8"));
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Merge-write the cache atomically (0600 in a 0700 dir). Never throws. */
|
|
55
|
+
export function writeCache(patch, env = process.env) {
|
|
56
|
+
try {
|
|
57
|
+
const filePath = updateCachePath(env);
|
|
58
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
59
|
+
const next = { ...(readCache(env) || {}), ...patch };
|
|
60
|
+
const tmp = `${filePath}.tmp`;
|
|
61
|
+
writeFileSync(tmp, `${JSON.stringify(next)}\n`, { mode: 0o600 });
|
|
62
|
+
renameSync(tmp, filePath);
|
|
63
|
+
chmodSync(filePath, 0o600);
|
|
64
|
+
} catch {
|
|
65
|
+
/* best-effort */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True if `latest` is a strictly higher release than `current`, comparing the
|
|
71
|
+
* numeric major.minor.patch only (a prerelease suffix is ignored — we never nudge
|
|
72
|
+
* a stable user onto a prerelease). Malformed input compares as not-newer.
|
|
73
|
+
*/
|
|
74
|
+
export function isNewer(latest, current) {
|
|
75
|
+
const parts = (s) =>
|
|
76
|
+
String(s || "").split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
|
|
77
|
+
const a = parts(latest);
|
|
78
|
+
const b = parts(current);
|
|
79
|
+
for (let i = 0; i < 3; i++) {
|
|
80
|
+
const x = a[i] || 0;
|
|
81
|
+
const y = b[i] || 0;
|
|
82
|
+
if (x !== y) return x > y;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Decide what (if anything) to tell the user, from the cached state only.
|
|
89
|
+
* Returns null or { level: "required"|"recommended"|"available", latest, message? }.
|
|
90
|
+
* Server policy (Layer 2) outranks the plain client compare (Layer 1).
|
|
91
|
+
*/
|
|
92
|
+
export function updateNotice(current, env = process.env) {
|
|
93
|
+
const cache = readCache(env);
|
|
94
|
+
if (!cache) return null;
|
|
95
|
+
|
|
96
|
+
const policy = cache.policy;
|
|
97
|
+
if (policy) {
|
|
98
|
+
if (policy.minSupported && isNewer(policy.minSupported, current)) {
|
|
99
|
+
return {
|
|
100
|
+
level: "required",
|
|
101
|
+
latest: policy.recommended || cache.latest || policy.minSupported,
|
|
102
|
+
message: policy.message,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (policy.recommended && isNewer(policy.recommended, current)) {
|
|
106
|
+
return { level: "recommended", latest: policy.recommended, message: policy.message };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (cache.latest && isNewer(cache.latest, current)) {
|
|
111
|
+
return { level: "available", latest: cache.latest };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** House-style stderr nudge for a notice. */
|
|
117
|
+
export function renderNotice(notice, current) {
|
|
118
|
+
const to = notice.latest ? ` ${current} → ${notice.latest}` : "";
|
|
119
|
+
const upgrade = ` npm i -g ${PKG}`;
|
|
120
|
+
if (notice.level === "required") {
|
|
121
|
+
return [
|
|
122
|
+
`\n✗ This version of \`tot\` (${current}) is no longer supported.`,
|
|
123
|
+
notice.message ? ` ${notice.message}` : null,
|
|
124
|
+
` → update to continue:${upgrade}`,
|
|
125
|
+
"",
|
|
126
|
+
]
|
|
127
|
+
.filter((l) => l !== null)
|
|
128
|
+
.join("\n");
|
|
129
|
+
}
|
|
130
|
+
const head =
|
|
131
|
+
notice.level === "recommended"
|
|
132
|
+
? `\n▸ A recommended update to \`tot\` is available (${current} → ${notice.latest}).`
|
|
133
|
+
: `\n▸ Update available for \`tot\`:${to}`;
|
|
134
|
+
return [head, notice.message ? ` ${notice.message}` : null, ` →${upgrade}`, ""]
|
|
135
|
+
.filter((l) => l !== null)
|
|
136
|
+
.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether we should print a nudge at all in this environment. */
|
|
140
|
+
export function shouldShowNudge(env = process.env, stream = process.stderr) {
|
|
141
|
+
if (env.TOT_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER) return false;
|
|
142
|
+
if (env.CI) return false;
|
|
143
|
+
if (!stream || !stream.isTTY) return false;
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function cacheIsFresh(env) {
|
|
148
|
+
const cache = readCache(env);
|
|
149
|
+
if (!cache || typeof cache.checkedAt !== "number") return false;
|
|
150
|
+
return Date.now() - cache.checkedAt < CHECK_INTERVAL_MS;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Fetch the `latest` dist-tag from the registry and write it to the cache. Used by
|
|
155
|
+
* the detached worker; also directly callable/testable. Never throws.
|
|
156
|
+
*/
|
|
157
|
+
export async function runRefresh(env = process.env) {
|
|
158
|
+
try {
|
|
159
|
+
const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
|
|
160
|
+
const metaUrl = `${registry}/${PKG.replace("/", "%2f")}`;
|
|
161
|
+
const res = await fetch(metaUrl, {
|
|
162
|
+
headers: { accept: "application/json" },
|
|
163
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok) return;
|
|
166
|
+
const meta = await res.json();
|
|
167
|
+
const latest = meta?.["dist-tags"]?.latest;
|
|
168
|
+
if (latest) writeCache({ latest, checkedAt: Date.now() }, env);
|
|
169
|
+
} catch {
|
|
170
|
+
/* best-effort: offline / timeout / bad JSON — just skip */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Record a server-issued support policy (Layer 2). Call this from any command that
|
|
176
|
+
* receives version guidance back from the MCP. `policy` is
|
|
177
|
+
* { minSupported?, recommended?, message? }. Never throws.
|
|
178
|
+
*/
|
|
179
|
+
export function recordServerPolicy(policy, env = process.env) {
|
|
180
|
+
if (policy && typeof policy === "object") writeCache({ policy }, env);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Spawn the cache refresh in a DETACHED, unref'd child so it never delays this
|
|
185
|
+
* process or holds it open. Skips when the cache is still fresh. Never throws.
|
|
186
|
+
*/
|
|
187
|
+
function refreshInBackground(env) {
|
|
188
|
+
try {
|
|
189
|
+
if (cacheIsFresh(env)) return;
|
|
190
|
+
const worker = fileURLToPath(new URL("./update-check-worker.mjs", import.meta.url));
|
|
191
|
+
const child = spawn(process.execPath, [worker], {
|
|
192
|
+
detached: true,
|
|
193
|
+
stdio: "ignore",
|
|
194
|
+
env,
|
|
195
|
+
});
|
|
196
|
+
child.unref();
|
|
197
|
+
} catch {
|
|
198
|
+
/* best-effort */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Top-level entry: draw a nudge from the last cached result (instant), then kick a
|
|
204
|
+
* background refresh if stale. Call once per invocation, e.g. as the CLI exits.
|
|
205
|
+
* Never throws, never blocks.
|
|
206
|
+
*/
|
|
207
|
+
export function maybeNotifyUpdate(current, env = process.env, { stream = process.stderr } = {}) {
|
|
208
|
+
try {
|
|
209
|
+
if (shouldShowNudge(env, stream)) {
|
|
210
|
+
const notice = updateNotice(current, env);
|
|
211
|
+
if (notice) stream.write(`${renderNotice(notice, current)}\n`);
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
/* never break a command over a nudge */
|
|
215
|
+
}
|
|
216
|
+
refreshInBackground(env);
|
|
217
|
+
}
|