Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { TUI } from './tui.js';
import { SessionTitles } from './session-titles.js';
import { RemoteControl, createAttachSession } from './tui-remote.js';
import { SxManager } from './sx.js';
import { autoUpdate, checkForUpdate, currentVersion, runUpdate, installKind, PKG_NAME } from './updater.js';
import { autoUpdate, checkForUpdate, currentVersion, resolveVersionLabel, runUpdate, installKind, updateAvailableFromCache, PKG_NAME } from './updater.js';
import { renderStatus, formatPercent } from './status-renderer.js';
import { sanitizeText } from './safe-text.js';
import { ClientUsageTracker, UsageDimensionTracker } from './client-usage.js';
Expand Down Expand Up @@ -369,6 +369,11 @@ async function serverCommand() {
// disk while this process keeps running the old code, and status must report
// what is running, not what is installed.
const serverVersion = currentVersion();
// What the header names this build, and whether the last recorded check saw
// something newer. A checkout gets no update marker: autoUpdate refuses to
// npm-install over one, so offering it would advertise a declined action.
const { label: versionLabel, git: fromGit } = await resolveVersionLabel();
const updateAvailable = !fromGit && await updateAvailableFromCache({ current: serverVersion });

// sx.org proxy (IP-based-429 workaround). Dormant unless an API key is set in
// config.sx.apiKey; when set we provision a proxy and route upstream through it.
Expand Down Expand Up @@ -466,7 +471,7 @@ async function serverCommand() {

if (useTUI) {
tui = new TUI({
accountManager, config, sx, activityLogPath, sessionTitles,
accountManager, config, sx, activityLogPath, sessionTitles, versionLabel, updateAvailable,
saveConfig: () => atomicConfigUpdate(async diskConfig => {
diskConfig.accounts = mergeAccountsForSave(
config.accounts, accountManager.accounts, diskConfig.accounts, removedAccountIds(config),
Expand Down Expand Up @@ -563,6 +568,8 @@ async function serverCommand() {
usageDimensions: dimensionUsage.export(),
server: {
version: serverVersion,
versionLabel,
updateAvailable,
startedAt: new Date(serverStartedAt).toISOString(),
uptimeSeconds: Math.round((Date.now() - serverStartedAt) / 1000),
port,
Expand Down
9 changes: 9 additions & 0 deletions src/tui-remote.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const text = (value, max, fallback = '') => {
return safeLine(value, max) || fallback;
};
const NAME_MAX = 64;
const LABEL_MAX = 32;

// Addresses that reach this machine. A server bound to one of these exempts
// loopback clients from the proxy-key gate, which changes what a 401 can mean.
Expand Down Expand Up @@ -198,6 +199,10 @@ export class RemoteAccountManager {
this.connected = false; // false ⇒ the view is a stale snapshot
this.lastError = null;
this.status = null;
// Empty until the first poll, so the header shows no version rather than
// this process's own — in attach mode that would name the wrong machine.
this.versionLabel = '';
this.updateAvailable = false;
}

/** Per-bucket threshold lookup, mirroring AccountManager.thresholdFor so the
Expand Down Expand Up @@ -266,6 +271,10 @@ export class RemoteAccountManager {
accounts: (Array.isArray(r?.accounts) ? r.accounts : [])
.map(a => ({ ...a, name: text(a?.name, NAME_MAX, '?'), eligible: !!a?.eligible })),
}));
// A server too old to send versionLabel still sends version; one older than
// both leaves the label empty and the header simply omits it.
this.versionLabel = text(status?.server?.versionLabel ?? status?.server?.version, LABEL_MAX);
this.updateAvailable = !!status?.server?.updateAvailable;
this.status = status;
this.connected = true;
this.lastError = null;
Expand Down
34 changes: 32 additions & 2 deletions src/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,11 @@ const BAR_MAX = 20;
// terminal lays the table out exactly as it did before the column could grow.
const NAME_MIN = 12;

// Clear space the centred version label needs on each side before it is drawn
// at all. Below that it reads as a collision with the title or the port block,
// so the whole label is dropped rather than squeezed.
const HEAD_GAP = 2;

// Which pair of bars a row draws: the subscription buckets (Ses/Wk, plus the
// S7/F7 family bars) when any unified reading exists, else the metered Tok/Req
// pair an API-key account reports. The account row budget is drawn per
Expand Down Expand Up @@ -414,7 +419,11 @@ export class TUI {
readCredentials = importCredentials, readProfile = fetchProfile,
// Names the activity column against the session id the client sent. Absent
// or disabled leaves every row showing the short id.
sessionTitles = null }) {
sessionTitles = null,
// How the header names this build, and whether a newer release is known.
// In attach mode the account manager carries the server's own answer and
// these are unused; the empty defaults keep the label hidden until it does.
versionLabel = '', updateAvailable = false }) {
this.am = accountManager;
this.remote = remote;
this.applySwitch = applySwitch;
Expand All @@ -430,6 +439,8 @@ export class TUI {
this._readProfile = readProfile;
this._activityStream = null;
this.sessionTitles = sessionTitles;
this.versionLabel = versionLabel;
this.updateAvailable = updateAvailable;

this.log = []; // completed activity entries
this.active = new Map(); // in-flight requests
Expand Down Expand Up @@ -1384,7 +1395,26 @@ export class TUI {
// mode): what is on screen is the last snapshot, not the current state.
const live = this.am.connected === false ? red('▼') : green('▲');
const right = `${sessStr}Port ${port} ${live} `;
lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
// In attach mode the dashboard names the server's build, not this process's,
// so the account manager's answer wins. It arrives sanitized (applyStatus)
// and starts empty, which keeps the label hidden until the first poll rather
// than briefly showing the local checkout's version as if it were the
// server's. A local AccountManager has neither property.
const label = this.am.versionLabel ?? this.versionLabel;
const upd = this.am.updateAvailable ?? this.updateAvailable;
const mid = label ? dim(label) + (upd ? ` ${green('▲')}` : '') : '';
const lw = vw(left), rw = vw(right), mw = vw(mid);
// Centred on the line, not in the gap between the two blocks, so the label
// holds still as the session segment comes and goes.
const start = Math.floor((W - mw) / 2);
// Load-bearing, not cosmetic: both padding runs below would be negative
// without it, and ' '.repeat(-1) throws. Satisfying it also means the mid
// branch can never produce the over-wide line the other branch can, so the
// two are not interchangeable.
const midFits = mw > 0 && start - lw >= HEAD_GAP && (W - rw) - (start + mw) >= HEAD_GAP;
lines.push(midFits
? left + ' '.repeat(start - lw) + mid + ' '.repeat(W - rw - start - mw) + right
: left + ' '.repeat(Math.max(1, W - lw - rw)) + right);
lines.push(' ' + dim('─'.repeat(W - 2)));

const footerH = 2;
Expand Down
54 changes: 54 additions & 0 deletions src/updater.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ import { existsSync, readFileSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { getConfigPath } from './config.js';
import { safeLine } from './safe-text.js';

export const PKG_NAME = '@karpeleslab/teamclaude';
const REGISTRY = 'https://registry.npmjs.org';
const DAY_MS = 24 * 60 * 60 * 1000;
// Wide enough for `v1.2.3-rc.1+build`, narrow enough that a tag cannot be the
// reason a fixed-width caller has no room left.
const LABEL_MAX = 32;

const pexec = promisify(execFile);

/** Package root = one directory above this file's src/ directory. */
function packageRoot() {
Expand All @@ -35,6 +42,37 @@ export function currentVersion(root = packageRoot()) {
}
}

/**
* How the running copy identifies itself, for display: the exact tag when the
* checkout sits on one, else the short sha, else the shipped package.json
* version, else the literal `local`. `git` reports a checkout — npm cannot
* update one, so nothing should offer to.
*
* The git calls are pinned to the package root. `teamclaude server` is started
* from the operator's own project directory, and resolving against the process
* cwd would report that repository's sha as this package's version.
*
* @param {Object} [opts]
* @param {string} [opts.root]
* @param {(file: string, args: string[], options: { cwd: string, encoding: 'utf8', timeout: number }) => Promise<{ stdout: string }>} [opts.exec]
* @returns {Promise<{ label: string, git: boolean }>}
*/
export async function resolveVersionLabel({ root = packageRoot(), exec = pexec } = {}) {
const git = existsSync(join(root, '.git'));
if (git) {
/** @type {{ cwd: string, encoding: 'utf8', timeout: number }} */
const opts = { cwd: root, encoding: 'utf8', timeout: 2000 };
const probes = [['describe', '--tags', '--exact-match', 'HEAD'], ['rev-parse', '--short', 'HEAD']];
for (const args of probes) {
try {
const label = safeLine((await exec('git', args, opts)).stdout, LABEL_MAX);
if (label) return { label, git };
} catch { /* not on a tag, a shallow or broken checkout, or no git binary */ }
}
}
return { label: safeLine(currentVersion(root) || 'local', LABEL_MAX), git };
}

/**
* Numeric compare of x.y.z (prerelease/build suffix ignored). >0 if a is newer.
*
Expand Down Expand Up @@ -141,6 +179,22 @@ export async function checkForUpdate({
return { current, latest, updateAvailable: compareVersions(latest, current) > 0 };
}

/**
* Whether the last recorded check saw a newer release. Cache only — never the
* registry — so a caller on a render or status path costs nothing. Like
* `checkForUpdate`, the cached `latest` is used regardless of its age.
*
* @param {Object} [opts]
* @param {string|null} [opts.current]
* @param {string} [opts.cachePath]
* @returns {Promise<boolean>}
*/
export async function updateAvailableFromCache({ current = currentVersion(), cachePath = defaultCacheFile() } = {}) {
if (!current) return false;
const { latest } = await readCache(cachePath);
return !!latest && compareVersions(latest, current) > 0;
}

/**
* Whether `v` is a plain release version, the only shape we ever pass to npm.
*
Expand Down
4 changes: 4 additions & 0 deletions test/status-server-version.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ test('GET /teamclaude/status reports the running package version under server.ve
assert.match(version, /^\d+\.\d+\.\d+/, 'package.json carries a version to compare against');
assert.equal(status.server.version, version);
assert.equal(typeof status.server.startedAt, 'string', 'the rest of the server block is untouched');
// What the dashboard header draws: a tag or sha in a checkout, this version
// otherwise. Either way an attached client has something to name the build.
assert.match(status.server.versionLabel, /\S/);
assert.equal(typeof status.server.updateAvailable, 'boolean');
} finally {
await server.stop();
}
Expand Down
137 changes: 137 additions & 0 deletions test/tui-header-version.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { TUI, displayWidth } from '../src/tui.js';
import { RemoteAccountManager } from '../src/tui-remote.js';

// The header is one line built from three pieces that must add up to exactly
// the terminal width: the title, a centred version label, and the port block
// pinned to the right edge. Every case below is really the same assertion —
// the arithmetic holds, or the right edge drifts and fitLine eats the liveness
// marker.

function fakeAm(sessions) {
return {
accounts: [],
currentIndex: -1, switchThreshold: 0.98,
getRoutes() { return []; },
sessionStats() { return { active: sessions, known: sessions }; },
refreshExpiredQuotas() {},
thresholdFor() { return 0.98; },
};
}

function makeTUI({ am = fakeAm(0), ...opts } = {}) {
return new TUI({
accountManager: am, config: { proxy: { port: 1 }, accounts: [], routes: [] }, sx: null,
saveConfig: async () => {}, syncAccounts: async () => 0, onQuit: () => {}, probeQuota: null,
...opts,
});
}

/** One full frame at a given width, ANSI left in place. */
function renderRaw(tui, W) {
const cols = Object.getOwnPropertyDescriptor(process.stdout, 'columns');
const rows = Object.getOwnPropertyDescriptor(process.stdout, 'rows');
Object.defineProperty(process.stdout, 'columns', { value: W, configurable: true });
Object.defineProperty(process.stdout, 'rows', { value: 30, configurable: true });
let frame = '';
try {
tui._paint = buf => { frame = buf; };
tui.running = true;
tui.render(true);
} finally {
if (cols) Object.defineProperty(process.stdout, 'columns', cols);
if (rows) Object.defineProperty(process.stdout, 'rows', rows);
}
return frame;
}

const stripSgr = s => s.replace(/\x1b\[[0-9;]*m/g, '');
const cursorCodes = /\x1b\[H|\x1b\[\?25[hl]/g;
const header = (tui, W) => stripSgr(renderRaw(tui, W)).replace(cursorCodes, '').split('\r\n')[0];
const count = (s, ch) => [...s].filter(c => c === ch).length;

const WIDTHS = [40, 44, 50, 60, 80, 100, 120, 200];

test('the header is exactly the terminal width at every size, with or without a label', () => {
for (const W of WIDTHS) {
for (const sessions of [0, 3]) {
for (const updateAvailable of [false, true]) {
for (const versionLabel of ['', 'v1.2.3', '1.1.20-pr378']) {
const line = header(makeTUI({ am: fakeAm(sessions), versionLabel, updateAvailable }), W);
const where = `W=${W} sess=${sessions} upd=${updateAvailable} label=${versionLabel || '(none)'}`;
assert.equal(displayWidth(line), W, `${where}: width ${displayWidth(line)}`);
assert.ok(line.endsWith('Port 1 ▲ '), `${where}: right block clipped — ${JSON.stringify(line.slice(-12))}`);
}
}
}
}
});

test('the label is centred on the line and does not move when sessions appear', () => {
const at = sessions => header(makeTUI({ am: fakeAm(sessions), versionLabel: 'v1.2.3' }), 100).indexOf('v1.2.3');
assert.equal(at(0), Math.floor((100 - 'v1.2.3'.length) / 2));
assert.equal(at(3), at(0));
});

test('the update marker is a second triangle, drawn only when an update is known', () => {
const on = header(makeTUI({ versionLabel: 'v1.2.3', updateAvailable: true }), 100);
assert.match(on, /v1\.2\.3 ▲/);
assert.equal(count(on, '▲'), 2);

const off = header(makeTUI({ versionLabel: 'v1.2.3', updateAvailable: false }), 100);
assert.match(off, /v1\.2\.3(?! ▲)/);
assert.equal(count(off, '▲'), 1);
});

test('a header too narrow for the label drops it whole, falling back verbatim', () => {
const narrow = header(makeTUI({ am: fakeAm(3), versionLabel: '1.1.20-pr378', updateAvailable: true }), 40);
assert.doesNotMatch(narrow, /1\.1\.20/);
assert.equal(count(narrow, '▲'), 1);
assert.equal(narrow, header(makeTUI({ am: fakeAm(3) }), 40));
});

test('a TUI given no label renders the header it rendered before there was one', () => {
assert.equal(header(makeTUI(), 80), header(makeTUI({ versionLabel: '' }), 80));
});

// ── attach mode ──────────────────────────────────────────────

function remoteTUI(server, opts = {}) {
const am = new RemoteAccountManager();
am.applyStatus({
currentAccount: 'a@example.com', switchThreshold: 0.98, routes: [],
sessions: { active: 0, known: 0, perAccount: {} },
accounts: [{ name: 'a@example.com', type: 'oauth', status: 'active', usage: {}, quota: {} }],
...(server ? { server } : {}),
});
return makeTUI({ am, remote: true, applySwitch: async () => {}, ...opts });
}

test('the attached dashboard names the server build, not its own', () => {
const line = header(remoteTUI(
{ version: '1.0.0', versionLabel: 'v9.9.9', updateAvailable: true },
{ versionLabel: 'ignored-local', updateAvailable: false },
), 100);
assert.match(line, /v9\.9\.9 ▲/);
assert.doesNotMatch(line, /ignored-local/);
});

test('a server that only sends version still gets a label, and no update marker', () => {
const line = header(remoteTUI({ version: '1.0.0' }), 100);
assert.match(line, /1\.0\.0/);
assert.equal(count(line, '▲'), 1);
});

test('a server that sends no version block leaves the header as it was', () => {
const line = header(remoteTUI(null), 100);
assert.equal(displayWidth(line), 100);
assert.equal(count(line, '▲'), 1);
});

test('a version label off the wire cannot put an escape sequence in the frame', () => {
const frame = renderRaw(remoteTUI({ versionLabel: 'v1\x1b[2J\x1b]52;c;aGk=\x07evil' }), 100);
const rest = stripSgr(frame).replace(cursorCodes, '');
assert.doesNotMatch(rest, /[\x1b\x07\x9b]/);
assert.match(rest, /v1/);
});
Loading