@expitc/termad 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,54 @@
1
1
  #!/usr/bin/env node
2
- import { runWithAdOverlay } from "@expitc/termad/dist/surfaces/pty-inject.js";
2
+ /**
3
+ * Launcher for the PTY overlay.
4
+ *
5
+ * Must work from any install layout:
6
+ * <pkg>/dist/assets/pty-overlay.mjs (published package, global install)
7
+ * <pkg>/src/assets/pty-overlay.mjs (running from source)
8
+ * ~/.termad/pty-overlay.mjs (written by `termad install`)
9
+ *
10
+ * Resolves pty-inject.js relative to itself first, then by package name.
11
+ * If nothing resolves, exit non-zero WITHOUT touching the CLI — the
12
+ * wrapper's own pre-banner fallback takes over and the CLI still runs.
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+
20
+ const candidates = [
21
+ join(here, "..", "surfaces", "pty-inject.js"), // dist/assets → dist/surfaces
22
+ join(here, "surfaces", "pty-inject.js"), // src/assets → src/surfaces
23
+ ];
24
+
25
+ let runWithAdOverlay = null;
26
+
27
+ for (const c of candidates) {
28
+ if (!existsSync(c)) continue;
29
+ try {
30
+ ({ runWithAdOverlay } = await import(pathToFileURL(c).href));
31
+ break;
32
+ } catch {}
33
+ }
34
+
35
+ if (!runWithAdOverlay) {
36
+ try {
37
+ ({ runWithAdOverlay } = await import("@expitc/termad/dist/surfaces/pty-inject.js"));
38
+ } catch {}
39
+ }
40
+
41
+ if (typeof runWithAdOverlay !== "function") {
42
+ console.error("termad: pty-inject.js not found — falling back to banner mode");
43
+ process.exit(1);
44
+ }
45
+
3
46
  const binary = process.argv[2];
4
47
  const args = process.argv.slice(3);
5
- runWithAdOverlay(binary, args);
48
+
49
+ if (!binary) {
50
+ console.error("termad: usage: pty-overlay.mjs <binary> [args...]");
51
+ process.exit(1);
52
+ }
53
+
54
+ await runWithAdOverlay(binary, args);
@@ -9,13 +9,13 @@
9
9
  TERMAD_AD_FILE="__TERMAD_AD__"
10
10
  TERMAD_BACKUP="__TERMAD_BACKUP__"
11
11
  TERMAD_COUNT="__TERMAD_COUNT__"
12
- TERMAD_HOME_DIR="$(dirname "$(dirname "$TERMAD_AD_FILE")")"
12
+ TERMAD_HOME_DIR="$(dirname "$TERMAD_AD_FILE")"
13
13
 
14
14
  # Try PTY overlay via node script bundled with termad
15
15
  OVERLAY_SCRIPT="$TERMAD_HOME_DIR/pty-overlay.mjs"
16
16
  if [ ! -f "$OVERLAY_SCRIPT" ]; then
17
17
  # Try to find in node_modules or global install
18
- OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/surfaces/pty-inject.mjs"
18
+ OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/assets/pty-overlay.mjs"
19
19
  fi
20
20
 
21
21
  if [ -f "$OVERLAY_SCRIPT" ] && command -v node >/dev/null 2>&1; then
@@ -1,5 +1,54 @@
1
1
  #!/usr/bin/env node
2
- import { runWithAdOverlay } from "@expitc/termad/dist/surfaces/pty-inject.js";
2
+ /**
3
+ * Launcher for the PTY overlay.
4
+ *
5
+ * Must work from any install layout:
6
+ * <pkg>/dist/assets/pty-overlay.mjs (published package, global install)
7
+ * <pkg>/src/assets/pty-overlay.mjs (running from source)
8
+ * ~/.termad/pty-overlay.mjs (written by `termad install`)
9
+ *
10
+ * Resolves pty-inject.js relative to itself first, then by package name.
11
+ * If nothing resolves, exit non-zero WITHOUT touching the CLI — the
12
+ * wrapper's own pre-banner fallback takes over and the CLI still runs.
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+
20
+ const candidates = [
21
+ join(here, "..", "surfaces", "pty-inject.js"), // dist/assets → dist/surfaces
22
+ join(here, "surfaces", "pty-inject.js"), // src/assets → src/surfaces
23
+ ];
24
+
25
+ let runWithAdOverlay = null;
26
+
27
+ for (const c of candidates) {
28
+ if (!existsSync(c)) continue;
29
+ try {
30
+ ({ runWithAdOverlay } = await import(pathToFileURL(c).href));
31
+ break;
32
+ } catch {}
33
+ }
34
+
35
+ if (!runWithAdOverlay) {
36
+ try {
37
+ ({ runWithAdOverlay } = await import("@expitc/termad/dist/surfaces/pty-inject.js"));
38
+ } catch {}
39
+ }
40
+
41
+ if (typeof runWithAdOverlay !== "function") {
42
+ console.error("termad: pty-inject.js not found — falling back to banner mode");
43
+ process.exit(1);
44
+ }
45
+
3
46
  const binary = process.argv[2];
4
47
  const args = process.argv.slice(3);
5
- runWithAdOverlay(binary, args);
48
+
49
+ if (!binary) {
50
+ console.error("termad: usage: pty-overlay.mjs <binary> [args...]");
51
+ process.exit(1);
52
+ }
53
+
54
+ await runWithAdOverlay(binary, args);
@@ -9,13 +9,13 @@
9
9
  TERMAD_AD_FILE="__TERMAD_AD__"
10
10
  TERMAD_BACKUP="__TERMAD_BACKUP__"
11
11
  TERMAD_COUNT="__TERMAD_COUNT__"
12
- TERMAD_HOME_DIR="$(dirname "$(dirname "$TERMAD_AD_FILE")")"
12
+ TERMAD_HOME_DIR="$(dirname "$TERMAD_AD_FILE")"
13
13
 
14
14
  # Try PTY overlay via node script bundled with termad
15
15
  OVERLAY_SCRIPT="$TERMAD_HOME_DIR/pty-overlay.mjs"
16
16
  if [ ! -f "$OVERLAY_SCRIPT" ]; then
17
17
  # Try to find in node_modules or global install
18
- OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/surfaces/pty-inject.mjs"
18
+ OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/assets/pty-overlay.mjs"
19
19
  fi
20
20
 
21
21
  if [ -f "$OVERLAY_SCRIPT" ] && command -v node >/dev/null 2>&1; then
@@ -6,6 +6,8 @@ export declare const paths: {
6
6
  adCache: () => string;
7
7
  /** Claude statusline launcher installed into ~/.claude/settings.json. */
8
8
  statuslineScript: () => string;
9
+ /** PTY overlay launcher the shell wrappers exec. Written by `termad install`. */
10
+ ptyOverlay: () => string;
9
11
  /** Captured pre-install statusLine (chain capture, like claude does natively). */
10
12
  prevStatusline: () => string;
11
13
  log: () => string;
@@ -22,3 +24,10 @@ export declare const paths: {
22
24
  export declare function backupPath(target: string): string;
23
25
  export declare const WRAPPER_MARKER = "===== TERMAD-CLI =====";
24
26
  export declare const OVERLAY_MARKER = "===== TERMAD-OVERLAY =====";
27
+ /** Every marker termad can write into a wrapped target.
28
+ * A target is termad's own if it carries ANY of them — checking only one
29
+ * makes overlay-wrapped binaries look untouched, which breaks uninstall
30
+ * (the original backup gets discarded) and misreports audit status. */
31
+ export declare const WRAPPER_MARKERS: readonly ["===== TERMAD-CLI =====", "===== TERMAD-OVERLAY ====="];
32
+ /** True if the content carries any termad wrapper marker. */
33
+ export declare function hasWrapperMarker(content: string): boolean;
@@ -10,6 +10,8 @@ export const paths = {
10
10
  adCache: () => join(termadHome(), "ad.txt"),
11
11
  /** Claude statusline launcher installed into ~/.claude/settings.json. */
12
12
  statuslineScript: () => join(termadHome(), "statusline.mjs"),
13
+ /** PTY overlay launcher the shell wrappers exec. Written by `termad install`. */
14
+ ptyOverlay: () => join(termadHome(), "pty-overlay.mjs"),
13
15
  /** Captured pre-install statusLine (chain capture, like claude does natively). */
14
16
  prevStatusline: () => join(termadHome(), "prev-statusline.json"),
15
17
  log: () => join(termadHome(), "termad.log"),
@@ -28,3 +30,12 @@ export function backupPath(target) {
28
30
  }
29
31
  export const WRAPPER_MARKER = "===== TERMAD-CLI =====";
30
32
  export const OVERLAY_MARKER = "===== TERMAD-OVERLAY =====";
33
+ /** Every marker termad can write into a wrapped target.
34
+ * A target is termad's own if it carries ANY of them — checking only one
35
+ * makes overlay-wrapped binaries look untouched, which breaks uninstall
36
+ * (the original backup gets discarded) and misreports audit status. */
37
+ export const WRAPPER_MARKERS = [WRAPPER_MARKER, OVERLAY_MARKER];
38
+ /** True if the content carries any termad wrapper marker. */
39
+ export function hasWrapperMarker(content) {
40
+ return WRAPPER_MARKERS.some((m) => content.includes(m));
41
+ }
package/dist/install.js CHANGED
@@ -7,7 +7,8 @@
7
7
  import { loadConfig, saveConfig, writeFileAtomic } from "./core/config.js";
8
8
  import { paths, termadHome, backupPath } from "./core/paths.js";
9
9
  import { fetchAd, writeAdCache } from "./core/source.js";
10
- import { existsSync, mkdirSync } from "node:fs";
10
+ import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
11
12
  import { getSurface } from "./surfaces/surfaces.js";
12
13
  import { installWrapper, uninstallWrapper, isWrapped } from "./surfaces/wrap.js";
13
14
  import { installStatusline, uninstallStatusline, settingsPath, isStatuslineInstalled } from "./surfaces/statusline.js";
@@ -46,11 +47,47 @@ export function planInstall(surfaces, force = false) {
46
47
  }
47
48
  return items;
48
49
  }
50
+ /** Absolute path to the overlay implementation shipped with THIS install.
51
+ * dist/surfaces/pty-inject.js in a published package, src/… when run from
52
+ * source. */
53
+ function ptyInjectPath() {
54
+ const here = dirname(new URL(import.meta.url).pathname);
55
+ const js = join(here, "surfaces", "pty-inject.js");
56
+ const ts = join(here, "surfaces", "pty-inject.ts");
57
+ return existsSync(js) ? js : ts;
58
+ }
59
+ /** Write the overlay launcher into termad home so the wrapper can always
60
+ * find it. The absolute path to pty-inject is baked in, which keeps the
61
+ * launcher working regardless of how termad itself was installed. */
62
+ function installOverlayLauncher() {
63
+ const target = paths.ptyOverlay();
64
+ try {
65
+ const mod = ptyInjectPath();
66
+ const body = `#!/usr/bin/env node
67
+ // Written by \`termad install\`. Launches the PTY ad overlay for a CLI binary.
68
+ // Regenerated on every install — safe to edit/delete; termad rewrites it.
69
+ import { pathToFileURL } from "node:url";
70
+ const mod = await import(pathToFileURL(${JSON.stringify(mod)}).href);
71
+ if (typeof mod.runWithAdOverlay !== "function") {
72
+ console.error("termad: overlay unavailable");
73
+ process.exit(1);
74
+ }
75
+ await mod.runWithAdOverlay(process.argv[2], process.argv.slice(3));
76
+ `;
77
+ writeFileSync(target, body);
78
+ chmodSync(target, 0o755);
79
+ return target;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
49
85
  /** Fetch the ad, write the cache, then install the opted-in surfaces. */
50
86
  export async function install(surfaces, opts = {}) {
51
87
  const force = opts.force ?? false;
52
88
  const cfg = loadConfig();
53
89
  mkdirSync(termadHome(), { recursive: true });
90
+ installOverlayLauncher();
54
91
  const ad = await fetchAd(surfaces?.[0] ?? "claude");
55
92
  writeAdCache(ad);
56
93
  const wanted = surfaces ?? Object.entries(cfg.surfaces).filter(([, v]) => v).map(([k]) => k);
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { createHash } from "node:crypto";
15
15
  import { copyFileSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
16
- import { WRAPPER_MARKER } from "../core/paths.js";
16
+ import { hasWrapperMarker } from "../core/paths.js";
17
17
  export const INLINE_MARKER = "===== TERMAD-INLINE =====";
18
18
  export const INLINE_BACKUP_EXT = ".termad-bundle-orig";
19
19
  export function inlineBackupPath(target) {
@@ -141,7 +141,7 @@ export function installInline(target) {
141
141
  function isWrappedCheck(target) {
142
142
  try {
143
143
  const head = readFileSync(target, "utf8").slice(0, 256);
144
- return head.includes(WRAPPER_MARKER);
144
+ return hasWrapperMarker(head);
145
145
  }
146
146
  catch {
147
147
  return false;
@@ -1,9 +1,10 @@
1
- import { readFileSync, appendFileSync } from "node:fs";
1
+ import { readFileSync, appendFileSync, existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  const TERMAD_HOME = process.env.TERMAD_HOME || join(homedir(), ".termad");
5
5
  const AD_FILE = join(TERMAD_HOME, "ad.txt");
6
6
  const COUNT_FILE = join(TERMAD_HOME, "display.log");
7
+ const CONFIG_FILE = join(TERMAD_HOME, "config.json");
7
8
  function readAd() {
8
9
  try {
9
10
  const raw = readFileSync(AD_FILE, "utf8").replace(/\r/g, "").split("\n");
@@ -18,6 +19,17 @@ function readAd() {
18
19
  return null;
19
20
  }
20
21
  }
22
+ function readStyle() {
23
+ try {
24
+ if (!existsSync(CONFIG_FILE))
25
+ return "line";
26
+ const cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
27
+ return cfg.style === "banner" ? "banner" : "line";
28
+ }
29
+ catch {
30
+ return "line";
31
+ }
32
+ }
21
33
  function count(id) {
22
34
  if (!id)
23
35
  return;
@@ -26,20 +38,65 @@ function count(id) {
26
38
  }
27
39
  catch { }
28
40
  }
29
- function buildFooter(ad) {
41
+ function osc8(url, text) {
42
+ if (!url)
43
+ return text;
44
+ return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
45
+ }
46
+ function getDomain(url) {
47
+ if (!url)
48
+ return null;
49
+ try {
50
+ const u = new URL(url);
51
+ return u.hostname.replace(/^www\./, "");
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ function truncate(s, n) {
58
+ if (s.length <= n)
59
+ return s;
60
+ return s.slice(0, Math.max(0, n - 3)) + "...";
61
+ }
62
+ function buildFooter(ad, style, width, offset = 0) {
30
63
  const label = "\x1b[2m\x1b[33m[ad]\x1b[0m";
31
- const text = ad.text.length > 80 ? ad.text.slice(0, 77) + "..." : ad.text;
32
- const url = ad.url && ad.url.length > 60 ? ad.url.slice(0, 57) + "..." : ad.url;
33
- let line = ` ${label} ${text}`;
34
- if (url)
35
- line += ` \x1b[36m${url}\x1b[0m`;
64
+ const domain = getDomain(ad.url);
65
+ const w = Math.max(40, width - 4);
66
+ if (style === "banner") {
67
+ // Boxed style like Kickbacks
68
+ const innerW = Math.max(30, w - 2);
69
+ let displayText = ad.text;
70
+ // Marquee for long text
71
+ if (displayText.length > innerW - 20) {
72
+ const padded = displayText + " • " + displayText;
73
+ const start = offset % displayText.length;
74
+ displayText = padded.slice(start, start + innerW - 25);
75
+ }
76
+ const linkText = domain || ad.url;
77
+ const linked = ad.url ? osc8(ad.url, `\x1b[36m${truncate(linkText, 30)}\x1b[0m`) : "";
78
+ const content = ` ${label} ${truncate(displayText, innerW - 15)}${linked ? " " + linked : ""}`;
79
+ const padded = content + " ".repeat(Math.max(0, innerW - content.replace(/\x1b\[[0-9;]*m/g, "").length));
80
+ return `\x1b[90m┌${"─".repeat(innerW)}┐\x1b[0m\n\x1b[90m│\x1b[0m${padded}\x1b[90m│\x1b[0m\n\x1b[90m└${"─".repeat(innerW)}┘\x1b[0m`;
81
+ }
82
+ // Line style (minimal, clean)
83
+ let displayText = ad.text;
84
+ if (displayText.length > w - 40) {
85
+ const padded = displayText + " ";
86
+ const start = offset % (displayText.length + 5);
87
+ displayText = padded.slice(start, start + w - 45);
88
+ }
89
+ const linkText = domain ? `\x1b[2m•\x1b[0m \x1b[36m${domain}\x1b[0m` : (ad.url ? `\x1b[2m•\x1b[0m \x1b[36m${truncate(ad.url, 40)}\x1b[0m` : "");
90
+ const linked = ad.url ? osc8(ad.url, linkText) : linkText;
91
+ let line = ` ${label} ${truncate(displayText, w - 35)}`;
92
+ if (linked)
93
+ line += ` ${linked}`;
36
94
  return line;
37
95
  }
38
96
  export function runWithAdOverlay(binary, args = []) {
39
97
  return runWithAdOverlayAsync(binary, args);
40
98
  }
41
99
  async function runWithAdOverlayAsync(binary, args = []) {
42
- // Try to use node-pty if available
43
100
  let pty;
44
101
  try {
45
102
  // @ts-ignore
@@ -53,12 +110,11 @@ async function runWithAdOverlayAsync(binary, args = []) {
53
110
  catch { }
54
111
  }
55
112
  if (!pty) {
56
- // No PTY available - fall back to printing banner then execing
57
113
  const ad = readAd();
58
114
  if (ad) {
59
- process.stdout.write("\n [ad] " + ad.text + "\n");
60
- if (ad.url)
61
- process.stdout.write(" " + ad.url + "\n\n");
115
+ const style = readStyle();
116
+ const width = process.stdout.columns || 120;
117
+ console.log(buildFooter(ad, style, width).replace(/\n/g, "\n"));
62
118
  count(ad.id);
63
119
  }
64
120
  const { spawn } = await import("node:child_process");
@@ -71,6 +127,7 @@ async function runWithAdOverlayAsync(binary, args = []) {
71
127
  count(ad.id);
72
128
  const cols = process.stdout.columns || 120;
73
129
  const rows = process.stdout.rows || 40;
130
+ const style = readStyle();
74
131
  const ptyProcess = pty.spawn(binary, args, {
75
132
  name: "xterm-256color",
76
133
  cols,
@@ -80,32 +137,46 @@ async function runWithAdOverlayAsync(binary, args = []) {
80
137
  });
81
138
  let footerVisible = !!ad;
82
139
  let lastAd = ad;
140
+ let marqueeOffset = 0;
83
141
  function drawFooter() {
84
142
  if (!footerVisible || !lastAd)
85
143
  return;
86
- const footer = buildFooter(lastAd);
87
- // Move to bottom row, clear line, print footer
144
+ const width = process.stdout.columns || cols;
88
145
  const termRows = ptyProcess.rows || rows;
89
- process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K${footer}\x1b[u`);
146
+ const footer = buildFooter(lastAd, style, width, marqueeOffset);
147
+ marqueeOffset++;
148
+ if (style === "banner") {
149
+ // Draw 3 lines for boxed
150
+ process.stdout.write(`\x1b[s\x1b[${termRows - 2};1H\x1b[2K${footer.split('\n')[0]}\x1b[u`);
151
+ process.stdout.write(`\x1b[s\x1b[${termRows - 1};1H\x1b[2K${footer.split('\n')[1]}\x1b[u`);
152
+ process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K${footer.split('\n')[2]}\x1b[u`);
153
+ }
154
+ else {
155
+ process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K${footer}\x1b[u`);
156
+ }
90
157
  }
91
158
  function hideFooter() {
92
159
  const termRows = ptyProcess.rows || rows;
93
- process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K\x1b[u`);
160
+ if (style === "banner") {
161
+ process.stdout.write(`\x1b[s\x1b[${termRows - 2};1H\x1b[2K\x1b[${termRows - 1};1H\x1b[2K\x1b[${termRows};1H\x1b[2K\x1b[u`);
162
+ }
163
+ else {
164
+ process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K\x1b[u`);
165
+ }
94
166
  }
95
- // Pipe PTY output to stdout with footer redrawn after each chunk
96
167
  ptyProcess.onData((data) => {
97
168
  process.stdout.write(data);
98
- // Redraw footer after a short delay to stay on top
99
- setTimeout(drawFooter, 10);
169
+ setTimeout(drawFooter, 15);
100
170
  });
101
- // Pipe stdin to PTY
102
171
  if (process.stdin.isTTY) {
103
- process.stdin.setRawMode(true);
172
+ try {
173
+ process.stdin.setRawMode(true);
174
+ }
175
+ catch { }
104
176
  }
105
177
  process.stdin.on("data", (data) => {
106
178
  ptyProcess.write(data);
107
179
  });
108
- // Handle resize
109
180
  process.stdout.on("resize", () => {
110
181
  const c = process.stdout.columns || cols;
111
182
  const r = process.stdout.rows || rows;
@@ -115,7 +186,6 @@ async function runWithAdOverlayAsync(binary, args = []) {
115
186
  catch { }
116
187
  setTimeout(drawFooter, 50);
117
188
  });
118
- // Handle exit
119
189
  ptyProcess.onExit(({ exitCode }) => {
120
190
  if (process.stdin.isTTY) {
121
191
  try {
@@ -127,7 +197,6 @@ async function runWithAdOverlayAsync(binary, args = []) {
127
197
  hideFooter();
128
198
  process.exit(exitCode ?? 0);
129
199
  });
130
- // Initial draw
131
- setTimeout(drawFooter, 100);
132
- setInterval(drawFooter, 500); // Keep footer visible on redraws
200
+ setTimeout(drawFooter, 150);
201
+ setInterval(drawFooter, style === "banner" ? 800 : 1000);
133
202
  }
@@ -8,7 +8,7 @@
8
8
  import { chmodSync, copyFileSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
9
9
  import { delimiter, dirname, join } from "node:path";
10
10
  import { homedir } from "node:os";
11
- import { WRAPPER_MARKER, backupPath, paths } from "../core/paths.js";
11
+ import { hasWrapperMarker, backupPath, paths } from "../core/paths.js";
12
12
  const SH_TEMPLATE_OVERLAY = "wrapper.sh.overlay";
13
13
  const SH_TEMPLATE = "wrapper.sh.asset";
14
14
  const CMD_TEMPLATE = "wrapper.cmd.asset";
@@ -21,7 +21,10 @@ function templatePath(isWin, useOverlay = true) {
21
21
  }
22
22
  export function isWrapped(target) {
23
23
  try {
24
- return readFileSync(target, "utf8").includes(WRAPPER_MARKER);
24
+ // Any termad marker counts — the overlay variant is just as much
25
+ // "ours" as the plain wrapper. Missing it makes uninstall discard the
26
+ // pristine backup and leaves the shim behind.
27
+ return hasWrapperMarker(readFileSync(target, "utf8"));
25
28
  }
26
29
  catch {
27
30
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expitc/termad",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Opt-in, fully reversible text ads for terminal AI CLIs (claude, codex, opencode, gemini). Consent-first, tamper-evident audit, 50% runner revenue share.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,7 @@
31
31
  "files": [
32
32
  "dist",
33
33
  "assets",
34
+ "scripts/ensure-pty-helper.mjs",
34
35
  "README.md",
35
36
  "POLICY.md",
36
37
  "LICENSE"
@@ -39,6 +40,7 @@
39
40
  "build": "tsc && node scripts/copy-assets.mjs",
40
41
  "dev": "tsc --watch",
41
42
  "server": "node server/server.mjs",
43
+ "postinstall": "node scripts/ensure-pty-helper.mjs || exit 0",
42
44
  "prepublishOnly": "npm run build"
43
45
  },
44
46
  "engines": {
@@ -47,5 +49,11 @@
47
49
  "devDependencies": {
48
50
  "@types/node": "^22.10.0",
49
51
  "typescript": "^5.7.0"
52
+ },
53
+ "optionalDependencies": {
54
+ "node-pty": "^1.1.0"
55
+ },
56
+ "allowScripts": {
57
+ "node-pty@1.1.0": true
50
58
  }
51
59
  }
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * node-pty ships its prebuilt `spawn-helper` (the tiny helper that opens the
4
+ * pty) inside the npm tarball WITHOUT the executable bit, and npm >= 11
5
+ * blocks dependency install scripts by default — so the helper is never
6
+ * rebuilt and never made executable. The result is a confusing
7
+ * "posix_spawnp failed" error and a silent downgrade to banner mode.
8
+ *
9
+ * This postinstall just fixes the mode. It is a no-op if the helper is
10
+ * already executable, and a no-op if node-pty is not installed at all
11
+ * (it is an optional dependency).
12
+ */
13
+ import { chmodSync, existsSync, statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { createRequire } from "node:module";
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ function findHelper() {
20
+ let root;
21
+ try {
22
+ root = join(require.resolve("node-pty/package.json"), "..");
23
+ } catch {
24
+ return null;
25
+ }
26
+ const candidates = [
27
+ join(root, "build", "Release", "spawn-helper"),
28
+ join(root, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"),
29
+ ];
30
+ return candidates.find((c) => existsSync(c)) ?? null;
31
+ }
32
+
33
+ const helper = findHelper();
34
+ if (!helper) process.exit(0);
35
+
36
+ try {
37
+ const mode = statSync(helper).mode & 0o777;
38
+ if (mode & 0o111) process.exit(0); // already executable
39
+ chmodSync(helper, 0o755);
40
+ console.log("termad: made node-pty spawn-helper executable");
41
+ } catch (e) {
42
+ // Non-fatal — the overlay degrades to banner mode.
43
+ console.log(`termad: could not make node-pty spawn-helper executable: ${e.message}`);
44
+ }