1616 */
1717
1818import { spawnSync } from 'node:child_process' ;
19- import { mkdirSync , readFileSync , writeFileSync } from 'node:fs' ;
19+ import { existsSync , mkdirSync , readFileSync , rmSync , writeFileSync } from 'node:fs' ;
20+ import { homedir } from 'node:os' ;
2021import { dirname , join } from 'node:path' ;
2122
22- import { parseArgs , UsageError } from '../src/args.ts' ;
23+ import { integer , parseArgs , UsageError } from '../src/args.ts' ;
24+ import {
25+ SERVICE_NAME ,
26+ TIMER_NAME ,
27+ decide ,
28+ formatInterval ,
29+ isDue ,
30+ parseStatus ,
31+ renderService ,
32+ renderTimer ,
33+ } from '../src/selfupdate.ts' ;
2334import {
2435 credentialsPath ,
2536 keyStates ,
@@ -42,7 +53,8 @@ import {
4253
4354const USAGE = `Usage:
4455 cli-tools list
45- cli-tools update
56+ cli-tools update [--auto]
57+ cli-tools autoupdate [--install [--hours N] | --remove]
4658 cli-tools link [--force]
4759 cli-tools unlink
4860 cli-tools aliases [--install]
@@ -52,6 +64,9 @@ const USAGE = `Usage:
5264Commands:
5365 list Every command here, and whether it is on PATH
5466 update git pull, reinstall dependencies, relink
67+ "--auto" is the unattended form: at most once a day, and only on a
68+ clean checkout of the default branch with nothing unpushed
69+ autoupdate A systemd user timer that runs "update --auto" for you
5570 link Symlink the commands into ~/.local/bin
5671 unlink Remove the symlinks we own
5772 aliases Print the moshcode pit aliases, or write them with --install
@@ -62,17 +77,24 @@ Commands:
6277Keys (config set <key>):
6378 openai OPENAI_API_KEY generate-names
6479 anthropic ANTHROPIC_API_KEY generate-names
80+ perplexity PERPLEXITY_API_KEY ask-web
81+ elevenlabs ELEVENLABS_API_KEY tts
6582
6683Options:
6784 --force link: take over a symlink owned by another checkout
85+ update --auto: check now, ignoring the once-a-day stamp
6886 --install aliases: merge them into ~/.moshcode/aliases.json
87+ autoupdate: write and enable the systemd user timer
88+ --remove autoupdate: disable it and delete the units
89+ --hours N autoupdate --install: how often to check (default: 24)
90+ --auto update: the unattended form, safe to run from a timer
6991 --json list/aliases/config: machine-readable (config never prints a key)
7092 -h, --help
7193` ;
7294
7395const SPEC = {
74- boolean : [ '--force' , '--install' , '--json' , '-h' , '--help' ] ,
75- string : [ ] ,
96+ boolean : [ '--force' , '--install' , '--json' , '--auto' , '--remove' , '- h', '--help' ] ,
97+ string : [ '--hours' ] ,
7698} as const ;
7799
78100function runLinks ( root : string , args : readonly string [ ] ) : number {
@@ -105,6 +127,147 @@ function update(root: string): number {
105127 return runLinks ( root , [ ] ) ;
106128}
107129
130+ /** Where the last automatic check is remembered. */
131+ function stampPath ( env : NodeJS . ProcessEnv = process . env ) : string {
132+ const state = env . XDG_STATE_HOME || join ( env . HOME ?? homedir ( ) , '.local' , 'state' ) ;
133+ return join ( state , 'cli-tools' , 'update-stamp' ) ;
134+ }
135+
136+ function readStamp ( ) : number | null {
137+ try {
138+ return Number ( readFileSync ( stampPath ( ) , 'utf8' ) . trim ( ) ) ;
139+ } catch {
140+ return null ;
141+ }
142+ }
143+
144+ function writeStamp ( now : number ) : void {
145+ const path = stampPath ( ) ;
146+ mkdirSync ( dirname ( path ) , { recursive : true } ) ;
147+ writeFileSync ( path , `${ now } \n` ) ;
148+ }
149+
150+ /** `origin/HEAD` when the remote publishes it, else master. */
151+ function defaultBranch ( root : string ) : string {
152+ const result = spawnSync ( 'git' , [ 'symbolic-ref' , '--short' , 'refs/remotes/origin/HEAD' ] , {
153+ cwd : root ,
154+ encoding : 'utf8' ,
155+ } ) ;
156+ if ( result . status !== 0 ) return 'master' ;
157+ const name = ( result . stdout ?? '' ) . trim ( ) . split ( '/' ) . pop ( ) ;
158+ return name || 'master' ;
159+ }
160+
161+ /**
162+ * The unattended path: check rarely, move only when it is unambiguously safe.
163+ *
164+ * Every refusal is printed rather than swallowed. This normally runs from a
165+ * timer, where stderr lands in the journal, and "why is my checkout not
166+ * updating" is otherwise unanswerable without reproducing the decision by hand.
167+ */
168+ function autoUpdate ( root : string , force : boolean ) : number {
169+ const now = Date . now ( ) ;
170+ if ( ! force && ! isDue ( readStamp ( ) , now ) ) return 0 ;
171+
172+ // Stamped before the work, not after: a fetch that fails should not mean a
173+ // retry on every single invocation for as long as the network is down.
174+ writeStamp ( now ) ;
175+
176+ const fetched = spawnSync ( 'git' , [ 'fetch' , '--quiet' ] , { cwd : root , encoding : 'utf8' } ) ;
177+ if ( fetched . status !== 0 ) {
178+ process . stderr . write ( `update --auto: git fetch failed — ${ ( fetched . stderr ?? '' ) . trim ( ) } \n` ) ;
179+ return 0 ;
180+ }
181+
182+ const status = spawnSync ( 'git' , [ 'status' , '--porcelain=v2' , '--branch' ] , {
183+ cwd : root ,
184+ encoding : 'utf8' ,
185+ } ) ;
186+ if ( status . status !== 0 ) {
187+ process . stderr . write ( 'update --auto: could not read git status\n' ) ;
188+ return 0 ;
189+ }
190+
191+ const decision = decide ( parseStatus ( status . stdout ?? '' ) , {
192+ defaultBranch : defaultBranch ( root ) ,
193+ } ) ;
194+ if ( decision . action === 'skip' ) {
195+ process . stderr . write ( `update --auto: skipped — ${ decision . reason } \n` ) ;
196+ return 0 ;
197+ }
198+
199+ process . stderr . write ( `update --auto: ${ decision . reason } \n` ) ;
200+ return update ( root ) ;
201+ }
202+
203+ function unitDir ( env : NodeJS . ProcessEnv = process . env ) : string {
204+ return join ( env . XDG_CONFIG_HOME || join ( env . HOME ?? homedir ( ) , '.config' ) , 'systemd' , 'user' ) ;
205+ }
206+
207+ function systemctl ( args : readonly string [ ] ) : number {
208+ const result = spawnSync ( 'systemctl' , [ '--user' , ...args ] , { stdio : 'inherit' } ) ;
209+ if ( result . error ) {
210+ process . stderr . write ( 'autoupdate: systemctl --user is not available on this machine.\n' ) ;
211+ return 1 ;
212+ }
213+ return result . status ?? 1 ;
214+ }
215+
216+ /**
217+ * Install, remove or report the timer.
218+ *
219+ * systemd rather than cron because the units are declarative, `Persistent=true`
220+ * catches up a machine that was asleep, and the output of a failed run is in
221+ * the journal instead of an email nobody configured.
222+ */
223+ function autoupdate ( root : string , flags : Set < string > , hours : number ) : number {
224+ const dir = unitDir ( ) ;
225+ const service = join ( dir , SERVICE_NAME ) ;
226+ const timer = join ( dir , TIMER_NAME ) ;
227+
228+ if ( flags . has ( '--remove' ) ) {
229+ systemctl ( [ 'disable' , '--now' , TIMER_NAME ] ) ;
230+ for ( const path of [ service , timer ] ) {
231+ try {
232+ rmSync ( path ) ;
233+ } catch {
234+ // Already gone is the outcome we wanted.
235+ }
236+ }
237+ systemctl ( [ 'daemon-reload' ] ) ;
238+ process . stdout . write ( 'autoupdate: removed\n' ) ;
239+ return 0 ;
240+ }
241+
242+ if ( flags . has ( '--install' ) ) {
243+ // The installed symlink is preferred over this checkout's path: it is the
244+ // name the operator actually uses, and it keeps working if the checkout
245+ // moves and is re-linked.
246+ const linked = join ( process . env . HOME ?? homedir ( ) , '.local' , 'bin' , 'cli-tools' ) ;
247+ const exec = existsSync ( linked ) ? linked : join ( root , 'bin' , 'cli-tools.ts' ) ;
248+
249+ mkdirSync ( dir , { recursive : true } ) ;
250+ writeFileSync ( service , renderService ( exec , process . env . PATH ) ) ;
251+ writeFileSync ( timer , renderTimer ( hours * 3600 ) ) ;
252+
253+ if ( systemctl ( [ 'daemon-reload' ] ) !== 0 ) return 1 ;
254+ if ( systemctl ( [ 'enable' , '--now' , TIMER_NAME ] ) !== 0 ) return 1 ;
255+
256+ process . stdout . write ( `autoupdate: enabled, every ${ formatInterval ( hours * 3600 ) } \n${ timer } \n` ) ;
257+ process . stdout . write (
258+ 'Note: user timers stop when you log out unless lingering is on\n' +
259+ ' (`loginctl enable-linger` — needs root).\n' ,
260+ ) ;
261+ return 0 ;
262+ }
263+
264+ if ( ! existsSync ( timer ) ) {
265+ process . stdout . write ( 'autoupdate: not installed — `cli-tools autoupdate --install`\n' ) ;
266+ return 0 ;
267+ }
268+ return systemctl ( [ 'list-timers' , '--all' , TIMER_NAME ] ) ;
269+ }
270+
108271function writeAliases ( ) : number {
109272 const path = aliasesPath ( ) ;
110273 let existing : Record < string , string > = { } ;
@@ -382,7 +545,9 @@ export async function run(argv: readonly string[]): Promise<number> {
382545 // Anything that is not one of ours is one of the commands: pass it straight
383546 // through, arguments and streams untouched, so `cli-tools gh-prs --orgs x`
384547 // behaves exactly as `gh-prs --orgs x` does.
385- const known = new Set ( [ 'list' , 'update' , 'link' , 'unlink' , 'aliases' , 'config' , 'where' ] ) ;
548+ const known = new Set ( [
549+ 'list' , 'update' , 'autoupdate' , 'link' , 'unlink' , 'aliases' , 'config' , 'where' ,
550+ ] ) ;
386551 if ( ! known . has ( command ) ) {
387552 const match = commands ( root ) . find ( ( entry ) => entry . name === command ) ;
388553 if ( ! match ) {
@@ -465,7 +630,16 @@ export async function run(argv: readonly string[]): Promise<number> {
465630 }
466631
467632 case 'update' :
468- return update ( root ) ;
633+ return options . flags . has ( '--auto' )
634+ ? autoUpdate ( root , options . flags . has ( '--force' ) )
635+ : update ( root ) ;
636+
637+ case 'autoupdate' :
638+ return autoupdate (
639+ root ,
640+ options . flags ,
641+ integer ( options . values , '--hours' , 24 , { min : 1 , max : 24 * 30 } ) ,
642+ ) ;
469643
470644 case 'link' :
471645 return runLinks ( root , options . flags . has ( '--force' ) ? [ '--force' ] : [ ] ) ;
0 commit comments