Skip to content

Commit 8578d84

Browse files
Feat/tui auto update (#15)
* feat(app): add auto-update notification and upgrade command Detect install method (npm/bun/curl), check npm registry for newer versions on TUI startup, and show a dismissable toast notification. Add CLI command for manual upgrades. Wire update status into the command palette and replace hardcoded footer version with package.json value. * chore(changeset): add changeset for auto-update feature * pr comments
1 parent 2e9b699 commit 8578d84

File tree

12 files changed

+413
-8
lines changed

12 files changed

+413
-8
lines changed

.changeset/easy-words-sniff.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@t-req/app": minor
3+
---
4+
5+
Add auto-update notification and upgrade command. The TUI checks npm for new versions on startup and shows a toastnotification. A new treq upgrade cli command allows manual upgrade. support npm, bun, and curl installations

packages/app/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { openCommand } from './cmd/open';
55
import { runCommand } from './cmd/run';
66
import { serveCommand } from './cmd/serve';
77
import { tuiCommand } from './cmd/tui';
8+
import { upgradeCommand } from './cmd/upgrade';
89

910
export function cli(args: string[]): void {
1011
yargs(hideBin(['node', 'cli', ...args]))
@@ -15,6 +16,7 @@ export function cli(args: string[]): void {
1516
.command(runCommand)
1617
.command(serveCommand)
1718
.command(tuiCommand)
19+
.command(upgradeCommand)
1820
.demandCommand(1, 'You need to specify a command')
1921
.strict()
2022
.help()

packages/app/src/cli/upgrade.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Installation } from '../installation';
2+
3+
export interface UpdateResult {
4+
version: string;
5+
method: Installation.Method;
6+
command: string;
7+
}
8+
9+
export async function checkForUpdate(): Promise<UpdateResult | undefined> {
10+
const method = await Installation.method();
11+
const latest = await Installation.latest(method).catch(() => undefined);
12+
if (!latest) return undefined;
13+
if (Installation.VERSION === latest) return undefined;
14+
return {
15+
version: latest,
16+
method,
17+
command: Installation.updateCommand(method, latest)
18+
};
19+
}

packages/app/src/cmd/upgrade.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import * as prompts from '@clack/prompts';
2+
import type { CommandModule } from 'yargs';
3+
import { Installation } from '../installation';
4+
5+
interface UpgradeOptions {
6+
target?: string;
7+
}
8+
9+
export const upgradeCommand: CommandModule<object, UpgradeOptions> = {
10+
command: 'upgrade [target]',
11+
describe: 'Upgrade treq to the latest or a specific version',
12+
builder: {
13+
target: {
14+
type: 'string',
15+
describe: 'Version to upgrade to, e.g. "0.2.1" or "v0.2.1"'
16+
}
17+
},
18+
handler: async (argv) => {
19+
prompts.intro('treq upgrade');
20+
21+
const detectedMethod = await Installation.method();
22+
if (detectedMethod === 'unknown') {
23+
prompts.log.warn(`Could not detect install method. treq is running from ${process.execPath}`);
24+
}
25+
26+
prompts.log.info(`Using method: ${detectedMethod}`);
27+
28+
const target = argv.target
29+
? argv.target.replace(/^v/, '')
30+
: await Installation.latest().catch(() => {
31+
prompts.log.error('Failed to fetch latest version from npm registry');
32+
return undefined;
33+
});
34+
35+
if (!target) {
36+
prompts.outro('Done');
37+
return;
38+
}
39+
40+
if (Installation.VERSION === target) {
41+
prompts.log.warn(`treq is already at version ${target}`);
42+
prompts.outro('Done');
43+
return;
44+
}
45+
46+
prompts.log.info(`From ${Installation.VERSION} -> ${target}`);
47+
48+
if (detectedMethod === 'unknown') {
49+
prompts.log.info(
50+
`Run manually:\n npm install -g @t-req/app@${target}\n bun install -g @t-req/app@${target}`
51+
);
52+
prompts.outro('Done');
53+
return;
54+
}
55+
56+
const spinner = prompts.spinner();
57+
spinner.start('Upgrading...');
58+
59+
try {
60+
await Installation.upgrade(detectedMethod, target);
61+
spinner.stop('Upgrade complete');
62+
} catch (err) {
63+
spinner.stop('Upgrade failed', 1);
64+
if (err instanceof Error) {
65+
prompts.log.error(err.message);
66+
}
67+
}
68+
69+
prompts.outro('Done');
70+
}
71+
};
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import path from 'node:path';
2+
import { $ } from 'bun';
3+
import pkg from '../../package.json';
4+
5+
export namespace Installation {
6+
export const VERSION: string = pkg.version;
7+
8+
export type Method = 'npm' | 'bun' | 'curl' | 'unknown';
9+
10+
export async function method(): Promise<Method> {
11+
// Check if installed via curl script (default: ~/.treq/bin)
12+
if (
13+
process.execPath.includes(path.join('.treq', 'bin')) ||
14+
process.execPath.includes(path.join('.local', 'bin'))
15+
) {
16+
return 'curl';
17+
}
18+
19+
const exec = process.execPath.toLowerCase();
20+
21+
const checks: Array<{ name: 'npm' | 'bun'; command: () => Promise<string> }> = [
22+
{
23+
name: 'npm',
24+
command: () => $`npm list -g --depth=0`.throws(false).quiet().text()
25+
},
26+
{
27+
name: 'bun',
28+
command: () => $`bun pm ls -g`.throws(false).quiet().text()
29+
}
30+
];
31+
32+
// Prioritize check matching the current runtime
33+
checks.sort((a, b) => {
34+
const aMatches = exec.includes(a.name);
35+
const bMatches = exec.includes(b.name);
36+
if (aMatches && !bMatches) return -1;
37+
if (!aMatches && bMatches) return 1;
38+
return 0;
39+
});
40+
41+
for (const check of checks) {
42+
const output = await check.command();
43+
if (output.includes('@t-req/app')) {
44+
return check.name;
45+
}
46+
}
47+
48+
return 'unknown';
49+
}
50+
51+
export async function latest(_installMethod?: Method): Promise<string> {
52+
const response = await fetch('https://registry.npmjs.org/@t-req/app/latest');
53+
if (!response.ok) throw new Error(response.statusText);
54+
const data = (await response.json()) as { version: string };
55+
return data.version;
56+
}
57+
58+
export async function upgrade(installMethod: Method, target: string): Promise<void> {
59+
let cmd: ReturnType<typeof $>;
60+
switch (installMethod) {
61+
case 'curl':
62+
cmd = $`curl -fsSL https://t-req.io/install | bash -s -- --version ${target}`;
63+
break;
64+
case 'npm':
65+
cmd = $`npm install -g @t-req/app@${target}`;
66+
break;
67+
case 'bun':
68+
cmd = $`bun install -g @t-req/app@${target}`;
69+
break;
70+
default:
71+
throw new Error(
72+
`Cannot auto-upgrade: unknown install method. Please upgrade manually:\n` +
73+
` npm install -g @t-req/app@${target}\n` +
74+
` bun install -g @t-req/app@${target}`
75+
);
76+
}
77+
const result = await cmd.quiet().throws(false);
78+
if (result.exitCode !== 0) {
79+
throw new Error(result.stderr.toString('utf8'));
80+
}
81+
}
82+
83+
export function updateCommand(installMethod: Method, target?: string): string {
84+
switch (installMethod) {
85+
case 'curl': {
86+
const versionFlag = target ? `--version ${target}` : '';
87+
return `curl -fsSL https://t-req.io/install | bash${versionFlag ? ` -s -- ${versionFlag}` : ''}`;
88+
}
89+
case 'npm': {
90+
const version = target ? `@${target}` : '';
91+
return `npm install -g @t-req/app${version}`;
92+
}
93+
case 'bun': {
94+
const version = target ? `@${target}` : '';
95+
return `bun install -g @t-req/app${version}`;
96+
}
97+
default: {
98+
const version = target ? `@${target}` : '';
99+
return `npm install -g @t-req/app${version}`;
100+
}
101+
}
102+
}
103+
}

packages/app/src/tui/app.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
StatusBar
3333
} from './layouts';
3434
import { openInEditor } from './editor';
35+
import { Toast } from './components/toast';
3536

3637
type LeftPanelMode = 'tree' | 'executions';
3738

@@ -311,6 +312,7 @@ export function App() {
311312
</SplitPanel>
312313

313314
<StatusBar isRunning={isRunning()} />
315+
<Toast />
314316
</FullScreenLayout>
315317
);
316318
}

packages/app/src/tui/components/command-dialog.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import type { JSX } from 'solid-js';
22
import { useDialog, type DialogContextValue } from '../context/dialog';
33
import { useExit } from '../context/exit';
44
import { useKeybind } from '../context/keybind';
5+
import { useUpdate } from '../context/update';
6+
import { Installation } from '../../installation';
7+
import { theme, rgba } from '../theme';
58
import { DebugConsoleDialog } from './debug-console-dialog';
69
import { DialogSelect, type DialogSelectOption } from './dialog-select';
710
import { FileRequestPicker } from './file-request-picker';
@@ -17,8 +20,36 @@ export function CommandDialog(): JSX.Element {
1720
const dialog = useDialog();
1821
const exit = useExit();
1922
const keybind = useKeybind();
23+
const update = useUpdate();
2024

2125
const commands: Command[] = [
26+
...(update.updateAvailable()
27+
? [
28+
{
29+
title: `Update Available (v${update.updateInfo()?.version})`,
30+
value: 'check_update',
31+
onSelect: (d: DialogContextValue) => {
32+
const info = update.updateInfo();
33+
if (info) {
34+
d.replace(() => (
35+
<box flexDirection="column" padding={1}>
36+
<text fg={rgba(theme.text)} attributes={1}>
37+
Update Available
38+
</text>
39+
<text fg={rgba(theme.textMuted)}>
40+
{`v${Installation.VERSION} -> v${info.version}`}
41+
</text>
42+
<text fg={rgba(theme.text)} marginTop={1}>
43+
Run:
44+
</text>
45+
<text fg={rgba(theme.primary)}>{info.command}</text>
46+
</box>
47+
));
48+
}
49+
}
50+
}
51+
]
52+
: []),
2253
{
2354
title: 'Debug Console',
2455
value: 'debug_console',

packages/app/src/tui/components/footer.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1+
import { Installation } from '../../installation';
12
import { theme, rgba } from '../theme';
23

3-
// Version from package.json - hardcoded for now, could be injected
4-
const VERSION = '0.1.0';
5-
64
export interface FooterProps {
75
workspacePath: string;
86
}
@@ -28,7 +26,7 @@ export function Footer(props: FooterProps) {
2826
backgroundColor={rgba(theme.background)}
2927
>
3028
<text fg={rgba(theme.textMuted)}>{displayPath()}</text>
31-
<text fg={rgba(theme.textMuted)}>v{VERSION}</text>
29+
<text fg={rgba(theme.textMuted)}>v{Installation.VERSION}</text>
3230
</box>
3331
);
3432
}

0 commit comments

Comments
 (0)