Skip to content

Commit 2e90d86

Browse files
feat(app): add startup auto-update for interactive commands (#96)
* feat(app): add startup auto-update for interactive commands Implements automatic update checks and optional auto-upgrade for treq open, tui, and web commands. Features include: - 24-hour check caching in ~/.treq/auto-update.json - 24-hour backoff for failed upgrade attempts - --auto-update/--no-auto-update CLI flags (default: enabled) - TREQ_AUTO_UPDATE environment variable support - TUI toast notifications for update states - Comprehensive test coverage for new update module * added strict precedence checks in auto update
1 parent b51beac commit 2e90d86

File tree

14 files changed

+1072
-38
lines changed

14 files changed

+1072
-38
lines changed

packages/app/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ treq open --port 8080
5858
| `--host, -H` | Host to bind to (default: 127.0.0.1) |
5959
| `--web` | Open the browser dashboard |
6060
| `--expose` | Allow non-loopback binding (disables cookie auth) |
61+
| `--auto-update` / `--no-auto-update` | Enable or disable startup auto-update checks (default: enabled) |
6162

6263
Security: a random token is generated on every launch. `--web` and `--expose` cannot be combined (SSRF protection).
6364

@@ -323,6 +324,7 @@ treq tui --server http://localhost:8080 --token my-token
323324
|--------|-------------|
324325
| `--server, -s` | Server URL to connect to (default: http://localhost:4097) |
325326
| `--token, -t` | Bearer token for authentication |
327+
| `--auto-update` / `--no-auto-update` | Enable or disable startup auto-update checks (default: enabled) |
326328

327329
### `treq upgrade` - Upgrade treq
328330

@@ -336,6 +338,16 @@ treq upgrade
336338
treq upgrade 0.3.0
337339
```
338340

341+
### Auto-update
342+
343+
Interactive commands (`treq open`, `treq tui`, `treq web`) perform startup update checks and attempt auto-upgrade for known install methods.
344+
345+
- Checks are cached for 24 hours in `~/.treq/auto-update.json`
346+
- Failed auto-upgrade attempts are backed off for 24 hours for the same target version
347+
- Successful installs apply on the next run (current process continues)
348+
- Use `--no-auto-update` to disable per command
349+
- Use `TREQ_AUTO_UPDATE=0` to disable via environment variable
350+
339351
### Help
340352

341353
```bash

packages/app/src/cli/upgrade.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,11 @@
1-
import { Installation } from '../installation';
1+
import { checkForAvailableUpdate } from '../update';
22

33
export interface UpdateResult {
44
version: string;
5-
method: Installation.Method;
5+
method: import('../installation').Installation.Method;
66
command: string;
77
}
88

99
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-
};
10+
return checkForAvailableUpdate();
1911
}

packages/app/src/cmd/open.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { resolve } from 'node:path';
22
import { flushPendingCookieSaves } from '@t-req/core/cookies/persistence';
33
import type { CommandModule } from 'yargs';
44
import { createApp, type ServerConfig } from '../server/app';
5+
import { resolveAutoUpdateEnabled } from '../update';
56
import {
67
DEFAULT_HOST,
78
DEFAULT_PORT,
@@ -19,6 +20,7 @@ interface OpenOptions {
1920
host: string;
2021
web?: boolean;
2122
expose?: boolean;
23+
autoUpdate?: boolean;
2224
}
2325

2426
export const openCommand: CommandModule<object, OpenOptions> = {
@@ -51,6 +53,11 @@ export const openCommand: CommandModule<object, OpenOptions> = {
5153
type: 'boolean',
5254
describe: 'Allow non-loopback binding (disables cookie auth for security)',
5355
default: false
56+
})
57+
.option('auto-update', {
58+
type: 'boolean',
59+
describe: 'Automatically check and apply updates on startup',
60+
default: true
5461
}),
5562
handler: async (argv) => {
5663
await runOpen(argv);
@@ -150,7 +157,12 @@ async function runOpen(argv: OpenOptions): Promise<void> {
150157
// Import and start TUI
151158
const { startTui } = await import('../tui');
152159
try {
153-
await startTui({ serverUrl, token, onExit: shutdown });
160+
await startTui({
161+
serverUrl,
162+
token,
163+
autoUpdate: resolveAutoUpdateEnabled(argv.autoUpdate),
164+
onExit: shutdown
165+
});
154166
} finally {
155167
// Cleanup on TUI exit
156168
await shutdown();

packages/app/src/cmd/tui.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { CommandModule } from 'yargs';
2+
import { resolveAutoUpdateEnabled } from '../update';
23

34
interface TuiOptions {
45
server: string;
56
token?: string;
7+
autoUpdate?: boolean;
68
}
79

810
export const tuiCommand: CommandModule<object, TuiOptions> = {
@@ -19,10 +21,19 @@ export const tuiCommand: CommandModule<object, TuiOptions> = {
1921
type: 'string',
2022
alias: 't',
2123
describe: 'Bearer token for authentication'
24+
},
25+
'auto-update': {
26+
type: 'boolean',
27+
default: true,
28+
describe: 'Automatically check and apply updates on startup'
2229
}
2330
},
2431
handler: async (argv) => {
2532
const { startTui } = await import('../tui');
26-
await startTui({ serverUrl: argv.server, token: argv.token });
33+
await startTui({
34+
serverUrl: argv.server,
35+
token: argv.token,
36+
autoUpdate: resolveAutoUpdateEnabled(argv.autoUpdate)
37+
});
2738
}
2839
};

packages/app/src/cmd/web.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { resolve } from 'node:path';
22
import { flushPendingCookieSaves } from '@t-req/core/cookies/persistence';
33
import type { CommandModule } from 'yargs';
44
import { createApp, type ServerConfig } from '../server/app';
5+
import { resolveAutoUpdateEnabled, runAutoUpdate } from '../update';
56
import {
67
DEFAULT_HOST,
78
DEFAULT_PORT,
@@ -16,6 +17,7 @@ interface WebOptions {
1617
workspace?: string;
1718
port?: number;
1819
host: string;
20+
autoUpdate?: boolean;
1921
}
2022

2123
export const webCommand: CommandModule<object, WebOptions> = {
@@ -38,6 +40,11 @@ export const webCommand: CommandModule<object, WebOptions> = {
3840
describe: 'Host to bind to',
3941
alias: 'H',
4042
default: DEFAULT_HOST
43+
})
44+
.option('auto-update', {
45+
type: 'boolean',
46+
describe: 'Automatically check and apply updates on startup',
47+
default: true
4148
}),
4249
handler: async (argv) => {
4350
await runWeb(argv);
@@ -114,6 +121,34 @@ async function runWeb(argv: WebOptions): Promise<void> {
114121
console.log(`Opening browser: ${webUrl}`);
115122
openBrowser(webUrl);
116123

124+
void runAutoUpdate({
125+
enabled: resolveAutoUpdateEnabled(argv.autoUpdate),
126+
interactive: process.stdout.isTTY === true
127+
}).then((outcome) => {
128+
switch (outcome.status) {
129+
case 'updated':
130+
console.log(`Update installed: v${outcome.latestVersion} (applies next run)`);
131+
break;
132+
case 'available_manual':
133+
console.log(`Update available: v${outcome.latestVersion}. Run: ${outcome.command}`);
134+
break;
135+
case 'backoff_skipped':
136+
console.warn(
137+
`Update available: v${outcome.latestVersion}. Auto-update temporarily paused.`
138+
);
139+
console.warn(`Run manually: ${outcome.command}`);
140+
break;
141+
case 'failed':
142+
if (outcome.phase === 'upgrade' && outcome.command) {
143+
console.warn('Auto-update failed. Continuing startup.');
144+
console.warn(`Run manually: ${outcome.command}`);
145+
}
146+
break;
147+
default:
148+
break;
149+
}
150+
});
151+
117152
process.on('SIGTERM', () => void shutdown());
118153
process.on('SIGINT', () => void shutdown());
119154

packages/app/src/tui/context/update.tsx

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import { createContext, createSignal, onMount, type ParentProps, useContext } from 'solid-js';
2-
import { Installation } from '../../installation';
2+
import type { UpdateInfo } from '../../update';
3+
import { runAutoUpdate } from '../../update';
34
import { useToast } from '../components/toast';
45

5-
export interface UpdateInfo {
6-
version: string;
7-
method: Installation.Method;
8-
command: string;
9-
}
6+
export type { UpdateInfo } from '../../update';
107

118
export interface UpdateContextValue {
129
updateInfo: () => UpdateInfo | null;
@@ -15,7 +12,11 @@ export interface UpdateContextValue {
1512

1613
const UpdateContext = createContext<UpdateContextValue>();
1714

18-
export function UpdateProvider(props: ParentProps) {
15+
export interface UpdateProviderProps extends ParentProps {
16+
autoUpdateEnabled?: boolean;
17+
}
18+
19+
export function UpdateProvider(props: UpdateProviderProps) {
1920
const toast = useToast();
2021
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null);
2122
const updateAvailable = () => updateInfo() !== null;
@@ -25,23 +26,76 @@ export function UpdateProvider(props: ParentProps) {
2526
});
2627

2728
async function checkForUpdate() {
28-
try {
29-
const method = await Installation.method();
30-
const latestVersion = await Installation.latest(method);
31-
if (!latestVersion) return;
32-
if (Installation.VERSION === latestVersion) return;
33-
34-
const command = Installation.updateCommand(method, latestVersion);
35-
setUpdateInfo({ version: latestVersion, method, command });
36-
37-
toast.show({
38-
variant: 'info',
39-
title: 'Update Available',
40-
message: `v${Installation.VERSION} -> v${latestVersion}\nRun: ${command}`,
41-
duration: 3000
42-
});
43-
} catch {
44-
// Silently fail - network errors, offline, etc.
29+
const outcome = await runAutoUpdate({
30+
enabled: props.autoUpdateEnabled ?? true,
31+
interactive: process.stdout.isTTY === true
32+
});
33+
34+
switch (outcome.status) {
35+
case 'available_manual': {
36+
setUpdateInfo({
37+
version: outcome.latestVersion,
38+
method: outcome.method,
39+
command: outcome.command
40+
});
41+
toast.show({
42+
variant: 'info',
43+
title: 'Update Available',
44+
message: `v${outcome.currentVersion} -> v${outcome.latestVersion}\nRun: ${outcome.command}`,
45+
duration: 3000
46+
});
47+
return;
48+
}
49+
50+
case 'backoff_skipped': {
51+
setUpdateInfo({
52+
version: outcome.latestVersion,
53+
method: outcome.method,
54+
command: outcome.command
55+
});
56+
toast.show({
57+
variant: 'warning',
58+
title: 'Update Available',
59+
message: `Auto-update paused after a recent failure.\nRun: ${outcome.command}`,
60+
duration: 4000
61+
});
62+
return;
63+
}
64+
65+
case 'updated': {
66+
toast.show({
67+
variant: 'success',
68+
title: 'Updated',
69+
message: `Installed v${outcome.latestVersion}. It will apply on your next run.`,
70+
duration: 3500
71+
});
72+
return;
73+
}
74+
75+
case 'failed': {
76+
if (
77+
outcome.phase === 'upgrade' &&
78+
outcome.latestVersion &&
79+
outcome.method &&
80+
outcome.command
81+
) {
82+
setUpdateInfo({
83+
version: outcome.latestVersion,
84+
method: outcome.method,
85+
command: outcome.command
86+
});
87+
toast.show({
88+
variant: 'warning',
89+
title: 'Auto-update failed',
90+
message: `Run manually: ${outcome.command}`,
91+
duration: 4000
92+
});
93+
}
94+
return;
95+
}
96+
97+
default:
98+
return;
4599
}
46100
}
47101

packages/app/src/tui/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { createStore } from './store';
2020
export interface TuiConfig {
2121
serverUrl: string;
2222
token?: string;
23+
autoUpdate?: boolean;
2324
onExit?: (reason?: unknown) => Promise<void> | void;
2425
}
2526

@@ -78,7 +79,7 @@ export async function startTui(config: TuiConfig): Promise<void> {
7879
<LogProvider>
7980
<ToastProvider>
8081
<DialogProvider>
81-
<UpdateProvider>
82+
<UpdateProvider autoUpdateEnabled={config.autoUpdate}>
8283
<App />
8384
</UpdateProvider>
8485
</DialogProvider>

0 commit comments

Comments
 (0)