88 mkdirSync,
99 readFileSync,
1010 renameSync,
11+ rmSync,
1112 symlinkSync,
1213 unlinkSync,
14+ writeFileSync,
1315} = require ( "node:fs" ) ;
1416const { dirname, join } = require ( "node:path" ) ;
1517const { spawnSync } = require ( "node:child_process" ) ;
@@ -19,55 +21,173 @@ const https = require("node:https");
1921
2022const packageDir = join ( __dirname , ".." ) ;
2123const runtimeDir = join ( packageDir , "bin-runtime" ) ;
22- const binaryPath = join ( runtimeDir , "attn" ) ;
24+ const runtimeBinaryPath = join ( runtimeDir , "attn" ) ;
2325const packageJsonPath = join ( packageDir , "package.json" ) ;
24- const installRoot = join ( homedir ( ) , ".local" , "share" , "attn" ) ;
25- const installBinaryPath = join ( installRoot , "bin" , "attn" ) ;
26- const installLinkDir = join ( homedir ( ) , ".local" , "bin" ) ;
26+ const userHome = homedir ( ) ;
27+
28+ const managedRoot = join ( userHome , ".local" , "share" , "attn" ) ;
29+ const managedAppsRoot = join ( managedRoot , "apps" ) ;
30+ const managedCurrentAppLink = join ( managedRoot , "current-app" ) ;
31+
32+ const installLinkDir = join ( userHome , ".local" , "bin" ) ;
2733const installLinkPath = join ( installLinkDir , "attn" ) ;
34+ const installLauncherPath = join ( managedRoot , "bin" , "attn-launcher.sh" ) ;
35+
2836const isInteractive = Boolean ( process . stdin . isTTY && process . stdout . isTTY ) ;
29- const isNpxInvocation = process . env . npm_execpath ?. includes ( "npx" ) || process . argv [ 1 ] ?. includes ( "attnmd" ) ;
37+ const isNpxInvocation =
38+ process . env . npm_execpath ?. includes ( "npx" ) || process . argv [ 1 ] ?. includes ( "attnmd" ) ;
39+
40+ const HEADLESS_FLAGS = new Set ( [
41+ "--status" ,
42+ "--json" ,
43+ "--check" ,
44+ "--info" ,
45+ "--eval" ,
46+ "--click" ,
47+ "--wait-for" ,
48+ "--query" ,
49+ "--fill" ,
50+ ] ) ;
3051
3152main ( ) . catch ( ( error ) => {
3253 console . error ( `attn: ${ error . message } ` ) ;
3354 process . exit ( 1 ) ;
3455} ) ;
3556
3657async function main ( ) {
37- if ( ! existsSync ( binaryPath ) ) {
38- await ensureRuntimeBinary ( ) ;
58+ const args = process . argv . slice ( 2 ) ;
59+ const packageJson = JSON . parse ( readFileSync ( packageJsonPath , "utf8" ) ) ;
60+ const version = packageJson . version ;
61+
62+ const appPath = await resolveAppPath ( version ) ;
63+ const headless = isHeadlessInvocation ( args ) ;
64+
65+ if ( isNpxInvocation ) {
66+ await maybePromptInstallAlias ( ) ;
3967 }
4068
41- if ( ! existsSync ( binaryPath ) ) {
42- throw new Error ( "runtime binary is missing after download attempt." ) ;
69+ if ( headless ) {
70+ const binaryPath = join ( appPath , "Contents" , "MacOS" , "attn" ) ;
71+ if ( ! existsSync ( binaryPath ) ) {
72+ throw new Error ( `managed app binary is missing at ${ binaryPath } ` ) ;
73+ }
74+ run ( binaryPath , args ) ;
75+ return ;
4376 }
4477
45- await maybePromptInstallAlias ( ) ;
46- run ( binaryPath , process . argv . slice ( 2 ) ) ;
78+ run ( "/usr/bin/open" , [ appPath , "--args" , ...args ] ) ;
4779}
4880
49- async function ensureRuntimeBinary ( ) {
50- const packageJson = JSON . parse ( readFileSync ( packageJsonPath , "utf8" ) ) ;
51- const version = packageJson . version ;
52- const assetSuffix = resolveAssetSuffix ( process . platform , process . arch ) ;
81+ async function resolveAppPath ( version ) {
82+ const globalApp = findGlobalAppInstall ( ) ;
83+ if ( globalApp ) {
84+ return globalApp ;
85+ }
5386
87+ const managedVersionApp = join ( managedAppsRoot , version , "attn.app" ) ;
88+ if ( existsSync ( managedVersionApp ) ) {
89+ ensureCurrentAppLink ( managedVersionApp ) ;
90+ return managedVersionApp ;
91+ }
92+
93+ await installManagedApp ( version ) ;
94+ if ( ! existsSync ( managedVersionApp ) ) {
95+ throw new Error ( `managed app install failed: ${ managedVersionApp } not found` ) ;
96+ }
97+
98+ ensureCurrentAppLink ( managedVersionApp ) ;
99+ pruneOldManagedApps ( version ) ;
100+ return managedVersionApp ;
101+ }
102+
103+ function findGlobalAppInstall ( ) {
104+ const candidates = [
105+ "/Applications/attn.app" ,
106+ join ( userHome , "Applications" , "attn.app" ) ,
107+ ] ;
108+ for ( const candidate of candidates ) {
109+ if ( existsSync ( candidate ) ) {
110+ return candidate ;
111+ }
112+ }
113+ return null ;
114+ }
115+
116+ function ensureCurrentAppLink ( appPath ) {
117+ mkdirSync ( managedRoot , { recursive : true } ) ;
118+ try {
119+ if ( existsSync ( managedCurrentAppLink ) ) {
120+ unlinkSync ( managedCurrentAppLink ) ;
121+ }
122+ } catch {
123+ rmSync ( managedCurrentAppLink , { recursive : true , force : true } ) ;
124+ }
125+ symlinkSync ( appPath , managedCurrentAppLink ) ;
126+ }
127+
128+ async function installManagedApp ( version ) {
129+ const assetSuffix = resolveAssetSuffix ( process . platform , process . arch ) ;
54130 if ( ! assetSuffix ) {
55131 throw new Error (
56132 `unsupported platform ${ process . platform } /${ process . arch } . Currently supported: darwin-arm64.`
57133 ) ;
58134 }
59135
60- const url = `https://github.com/lightsofapollo/attn/releases/download/v${ version } /attn-v${ version } -${ assetSuffix } ` ;
61- const tempPath = `${ binaryPath } .tmp` ;
62- mkdirSync ( runtimeDir , { recursive : true } ) ;
63- await download ( url , tempPath ) ;
64- chmodSync ( tempPath , 0o755 ) ;
65- renameSync ( tempPath , binaryPath ) ;
66- console . error ( `attn: installed runtime ${ binaryPath } ` ) ;
136+ const appZipName = `attn-v${ version } -${ assetSuffix } .app.zip` ;
137+ const appZipUrl = `https://github.com/lightsofapollo/attn/releases/download/v${ version } /${ appZipName } ` ;
138+
139+ const versionDir = join ( managedAppsRoot , version ) ;
140+ const tempZip = join ( versionDir , `${ appZipName } .tmp` ) ;
141+ const finalZip = join ( versionDir , appZipName ) ;
142+ const appPath = join ( versionDir , "attn.app" ) ;
143+
144+ mkdirSync ( versionDir , { recursive : true } ) ;
145+ await download ( appZipUrl , tempZip ) ;
146+ renameSync ( tempZip , finalZip ) ;
147+ unzipApp ( finalZip , versionDir ) ;
148+ chmodSync ( join ( appPath , "Contents" , "MacOS" , "attn" ) , 0o755 ) ;
149+ }
150+
151+ function unzipApp ( zipPath , outDir ) {
152+ const result = spawnSync (
153+ "/usr/bin/ditto" ,
154+ [ "-x" , "-k" , zipPath , outDir ] ,
155+ { stdio : "inherit" }
156+ ) ;
157+ if ( result . error ) {
158+ throw new Error ( `failed to extract app zip: ${ result . error . message } ` ) ;
159+ }
160+ if ( result . status !== 0 ) {
161+ throw new Error ( `failed to extract app zip: ditto exited ${ result . status } ` ) ;
162+ }
163+ }
164+
165+ function pruneOldManagedApps ( currentVersion ) {
166+ try {
167+ const keep = new Set ( [ currentVersion ] ) ;
168+ const listResult = spawnSync ( "ls" , [ "-1" , managedAppsRoot ] , {
169+ encoding : "utf8" ,
170+ } ) ;
171+ if ( listResult . status !== 0 || ! listResult . stdout ) {
172+ return ;
173+ }
174+ const versions = listResult . stdout
175+ . split ( "\n" )
176+ . map ( ( line ) => line . trim ( ) )
177+ . filter ( Boolean )
178+ . sort ( ) ;
179+
180+ for ( const version of versions ) {
181+ if ( keep . has ( version ) ) continue ;
182+ rmSync ( join ( managedAppsRoot , version ) , { recursive : true , force : true } ) ;
183+ }
184+ } catch {
185+ // Best effort cleanup only.
186+ }
67187}
68188
69189async function maybePromptInstallAlias ( ) {
70- if ( ! isInteractive || ! isNpxInvocation ) {
190+ if ( ! isInteractive ) {
71191 return ;
72192 }
73193 if ( existsSync ( installLinkPath ) ) {
@@ -80,12 +200,14 @@ async function maybePromptInstallAlias() {
80200 } ) ;
81201
82202 try {
83- const answer = await rl . question ( "Install `attn` command to ~/.local/bin for future runs? [Y/n] " ) ;
203+ const answer = await rl . question (
204+ "Install persistent `attn` command to ~/.local/bin for future runs? [Y/n] "
205+ ) ;
84206 const normalized = answer . trim ( ) . toLowerCase ( ) ;
85207 if ( normalized === "n" || normalized === "no" ) {
86208 return ;
87209 }
88- installAlias ( ) ;
210+ installAliasLauncher ( ) ;
89211 console . error ( "attn: installed ~/.local/bin/attn" ) ;
90212 } catch ( error ) {
91213 console . error ( `attn: failed to install alias: ${ error . message } ` ) ;
@@ -94,26 +216,51 @@ async function maybePromptInstallAlias() {
94216 }
95217}
96218
97- function installAlias ( ) {
98- mkdirSync ( dirname ( installBinaryPath ) , { recursive : true } ) ;
219+ function installAliasLauncher ( ) {
220+ mkdirSync ( dirname ( installLauncherPath ) , { recursive : true } ) ;
99221 mkdirSync ( installLinkDir , { recursive : true } ) ;
100- copyFileSync ( binaryPath , installBinaryPath ) ;
101- chmodSync ( installBinaryPath , 0o755 ) ;
222+
223+ const launcher = `#!/usr/bin/env bash
224+ set -euo pipefail
225+ APP_LINK="${ managedCurrentAppLink } "
226+ if [ ! -e "$APP_LINK" ]; then
227+ echo "attn: managed app is missing; run 'npx attnmd .' once to install." >&2
228+ exit 1
229+ fi
230+ BINARY="$APP_LINK/Contents/MacOS/attn"
231+ HEADLESS=0
232+ for arg in "$@"; do
233+ case "$arg" in
234+ --status|--json|--check|--info|--eval|--click|--wait-for|--query|--fill)
235+ HEADLESS=1
236+ ;;
237+ esac
238+ done
239+ if [ "$HEADLESS" -eq 1 ]; then
240+ exec "$BINARY" "$@"
241+ fi
242+ exec /usr/bin/open "$APP_LINK" --args "$@"
243+ ` ;
244+
245+ writeFileSync ( installLauncherPath , launcher , { mode : 0o755 } ) ;
246+ chmodSync ( installLauncherPath , 0o755 ) ;
102247
103248 if ( existsSync ( installLinkPath ) ) {
104249 unlinkSync ( installLinkPath ) ;
105250 }
106- symlinkSync ( installBinaryPath , installLinkPath ) ;
251+ symlinkSync ( installLauncherPath , installLinkPath ) ;
252+ }
253+
254+ function isHeadlessInvocation ( args ) {
255+ return args . some ( ( arg ) => HEADLESS_FLAGS . has ( arg ) ) ;
107256}
108257
109258function run ( cmd , args ) {
110259 const child = spawnSync ( cmd , args , {
111260 stdio : "inherit" ,
112261 } ) ;
113-
114262 if ( child . error ) {
115- console . error ( `attn: failed to launch binary: ${ child . error . message } ` ) ;
116- process . exit ( 1 ) ;
263+ throw new Error ( `failed to launch ${ cmd } : ${ child . error . message } ` ) ;
117264 }
118265 process . exit ( typeof child . status === "number" ? child . status : 1 ) ;
119266}
@@ -156,3 +303,4 @@ function download(url, destination) {
156303 request . on ( "error" , reject ) ;
157304 } ) ;
158305}
306+
0 commit comments