Skip to content

Commit 4b62638

Browse files
Ship signed app zip and make npx bootstrap managed app install
1 parent 0dc692e commit 4b62638

5 files changed

Lines changed: 199 additions & 36 deletions

File tree

.github/workflows/release.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,21 @@ jobs:
129129
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
130130
run: scripts/macos-sign-app.sh "$APP_PATH" "$APPLE_SIGNING_IDENTITY"
131131

132+
- name: Prepare app zip assets
133+
env:
134+
TAG: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || format('vdev-{0}', github.run_number) }}
135+
run: |
136+
VERSION="${TAG#v}"
137+
APP_DIR="$(dirname "$APP_PATH")"
138+
APP_NAME="$(basename "$APP_PATH")"
139+
ZIP_NAME="attn-v${VERSION}-darwin-arm64.app.zip"
140+
141+
cd "$APP_DIR"
142+
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "$ZIP_NAME"
143+
mv "$ZIP_NAME" "$GITHUB_WORKSPACE/dist/$ZIP_NAME"
144+
145+
shasum -a 256 "$GITHUB_WORKSPACE/dist/$ZIP_NAME" > "$GITHUB_WORKSPACE/dist/$ZIP_NAME.sha256"
146+
132147
- name: Create DMG
133148
env:
134149
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "attn"
3-
version = "0.1.15"
3+
version = "0.1.16"
44
edition = "2024"
55
description = "A beautiful markdown viewer that launches from the CLI"
66
license = "MIT"

bin/attn.js

Lines changed: 181 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ const {
88
mkdirSync,
99
readFileSync,
1010
renameSync,
11+
rmSync,
1112
symlinkSync,
1213
unlinkSync,
14+
writeFileSync,
1315
} = require("node:fs");
1416
const { dirname, join } = require("node:path");
1517
const { spawnSync } = require("node:child_process");
@@ -19,55 +21,173 @@ const https = require("node:https");
1921

2022
const packageDir = join(__dirname, "..");
2123
const runtimeDir = join(packageDir, "bin-runtime");
22-
const binaryPath = join(runtimeDir, "attn");
24+
const runtimeBinaryPath = join(runtimeDir, "attn");
2325
const 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");
2733
const installLinkPath = join(installLinkDir, "attn");
34+
const installLauncherPath = join(managedRoot, "bin", "attn-launcher.sh");
35+
2836
const 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

3152
main().catch((error) => {
3253
console.error(`attn: ${error.message}`);
3354
process.exit(1);
3455
});
3556

3657
async 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

69189
async 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

109258
function 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+

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "attnmd",
3-
"version": "0.1.15",
3+
"version": "0.1.16",
44
"description": "A beautiful markdown viewer that launches from the CLI",
55
"license": "MIT",
66
"repository": {

0 commit comments

Comments
 (0)