@expitc/termad 0.0.0-stage → 0.1.1
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.
- package/LICENSE +21 -0
- package/POLICY.md +91 -0
- package/README.md +94 -2
- package/assets/pty-overlay.mjs +5 -0
- package/assets/wrapper.cmd.asset +31 -0
- package/assets/wrapper.sh.asset +29 -0
- package/assets/wrapper.sh.overlay +45 -0
- package/dist/assets/pty-overlay.mjs +5 -0
- package/dist/assets/wrapper.cmd.asset +31 -0
- package/dist/assets/wrapper.sh.asset +29 -0
- package/dist/assets/wrapper.sh.overlay +45 -0
- package/dist/audit.d.ts +9 -0
- package/dist/audit.js +54 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +193 -0
- package/dist/core/ad.d.ts +19 -0
- package/dist/core/ad.js +12 -0
- package/dist/core/config.d.ts +18 -0
- package/dist/core/config.js +33 -0
- package/dist/core/manifest.d.ts +20 -0
- package/dist/core/manifest.js +42 -0
- package/dist/core/paths.d.ts +24 -0
- package/dist/core/paths.js +30 -0
- package/dist/core/source.d.ts +18 -0
- package/dist/core/source.js +78 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/install.d.ts +26 -0
- package/dist/install.js +182 -0
- package/dist/render/render.d.ts +20 -0
- package/dist/render/render.js +47 -0
- package/dist/report.d.ts +7 -0
- package/dist/report.js +91 -0
- package/dist/surfaces/inline.d.ts +17 -0
- package/dist/surfaces/inline.js +171 -0
- package/dist/surfaces/pty-inject.d.ts +1 -0
- package/dist/surfaces/pty-inject.js +133 -0
- package/dist/surfaces/statusline.d.ts +9 -0
- package/dist/surfaces/statusline.js +115 -0
- package/dist/surfaces/surfaces.d.ts +10 -0
- package/dist/surfaces/surfaces.js +47 -0
- package/dist/surfaces/wrap.d.ts +16 -0
- package/dist/surfaces/wrap.js +111 -0
- package/package.json +49 -4
package/dist/install.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install / uninstall orchestration across all opted-in surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Every change is recorded in the sha256 audit manifest BEFORE the user
|
|
5
|
+
* consents (the plan is shown) and re-verified by `termad audit`.
|
|
6
|
+
*/
|
|
7
|
+
import { loadConfig, saveConfig, writeFileAtomic } from "./core/config.js";
|
|
8
|
+
import { paths, termadHome, backupPath } from "./core/paths.js";
|
|
9
|
+
import { fetchAd, writeAdCache } from "./core/source.js";
|
|
10
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { getSurface } from "./surfaces/surfaces.js";
|
|
12
|
+
import { installWrapper, uninstallWrapper, isWrapped } from "./surfaces/wrap.js";
|
|
13
|
+
import { installStatusline, uninstallStatusline, settingsPath, isStatuslineInstalled } from "./surfaces/statusline.js";
|
|
14
|
+
import { loadManifest, recordEntry, removeEntries, sha256File } from "./core/manifest.js";
|
|
15
|
+
/** What `termad install` would change — shown to the user before consent. */
|
|
16
|
+
export function planInstall(surfaces, force = false) {
|
|
17
|
+
const cfg = loadConfig();
|
|
18
|
+
const wanted = surfaces ?? Object.entries(cfg.surfaces).filter(([, v]) => v).map(([k]) => k);
|
|
19
|
+
const items = [];
|
|
20
|
+
for (const id of wanted) {
|
|
21
|
+
if (id === "claude") {
|
|
22
|
+
items.push({
|
|
23
|
+
surface: id,
|
|
24
|
+
target: settingsPath(),
|
|
25
|
+
action: isStatuslineInstalled()
|
|
26
|
+
? "already chained — no change"
|
|
27
|
+
: "chain statusLine (your current statusLine captured, restored on uninstall)",
|
|
28
|
+
});
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const s = getSurface(id);
|
|
32
|
+
const bin = s?.locate();
|
|
33
|
+
if (!bin) {
|
|
34
|
+
items.push({ surface: id, target: "(not found)", action: `skip — CLI not found (set TERMAD_${id.toUpperCase()}_BIN)` });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
items.push({
|
|
38
|
+
surface: id,
|
|
39
|
+
target: bin,
|
|
40
|
+
action: isWrapped(bin)
|
|
41
|
+
? force
|
|
42
|
+
? "re-bake wrapper (pristine backup kept)"
|
|
43
|
+
: "already wrapped — no change"
|
|
44
|
+
: `wrap binary; pristine original → ${backupPath(bin)}`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return items;
|
|
48
|
+
}
|
|
49
|
+
/** Fetch the ad, write the cache, then install the opted-in surfaces. */
|
|
50
|
+
export async function install(surfaces, opts = {}) {
|
|
51
|
+
const force = opts.force ?? false;
|
|
52
|
+
const cfg = loadConfig();
|
|
53
|
+
mkdirSync(termadHome(), { recursive: true });
|
|
54
|
+
const ad = await fetchAd(surfaces?.[0] ?? "claude");
|
|
55
|
+
writeAdCache(ad);
|
|
56
|
+
const wanted = surfaces ?? Object.entries(cfg.surfaces).filter(([, v]) => v).map(([k]) => k);
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const id of wanted) {
|
|
59
|
+
const s = getSurface(id);
|
|
60
|
+
if (!s)
|
|
61
|
+
continue;
|
|
62
|
+
if (id === "claude") {
|
|
63
|
+
const target = settingsPath();
|
|
64
|
+
const shaBefore = sha256File(target);
|
|
65
|
+
const hadEntry = manifestHas(id, target);
|
|
66
|
+
const r = installStatusline();
|
|
67
|
+
if (r.ok && !hadEntry) {
|
|
68
|
+
recordEntry({
|
|
69
|
+
surface: id,
|
|
70
|
+
target,
|
|
71
|
+
mechanism: "statusline",
|
|
72
|
+
backup: existsSync(paths.prevStatusline()) ? paths.prevStatusline() : null,
|
|
73
|
+
sha256Original: shaBefore,
|
|
74
|
+
sha256Installed: sha256File(target),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
out.push({ surface: id, ok: r.ok, detail: r.alreadyInstalled ? "already installed" : r.ok ? "statusline chained" : (r.reason ?? "failed") });
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const bin = s.locate();
|
|
81
|
+
if (!bin) {
|
|
82
|
+
out.push({ surface: id, ok: false, detail: "CLI not found (set TERMAD_" + id.toUpperCase() + "_BIN)" });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const wasWrapped = isWrapped(bin);
|
|
86
|
+
const shaBefore = sha256File(bin);
|
|
87
|
+
const r = installWrapper(bin, paths.adCache(), force);
|
|
88
|
+
if (r.ok) {
|
|
89
|
+
const entry = loadManifest().entries.find((e) => e.surface === id && e.target === bin);
|
|
90
|
+
if (!entry) {
|
|
91
|
+
if (wasWrapped) {
|
|
92
|
+
// Legacy install (no manifest yet): original = the backup's bytes.
|
|
93
|
+
recordEntry({
|
|
94
|
+
surface: id,
|
|
95
|
+
target: bin,
|
|
96
|
+
mechanism: "wrapper",
|
|
97
|
+
backup: backupPath(bin),
|
|
98
|
+
sha256Original: sha256File(backupPath(bin)),
|
|
99
|
+
sha256Installed: shaBefore,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
recordEntry({
|
|
104
|
+
surface: id,
|
|
105
|
+
target: bin,
|
|
106
|
+
mechanism: "wrapper",
|
|
107
|
+
backup: backupPath(bin),
|
|
108
|
+
sha256Original: shaBefore,
|
|
109
|
+
sha256Installed: sha256File(bin),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (force && wasWrapped) {
|
|
114
|
+
// Re-bake: original stays, installed fingerprint follows the new wrapper.
|
|
115
|
+
recordEntry({
|
|
116
|
+
surface: entry.surface,
|
|
117
|
+
target: entry.target,
|
|
118
|
+
mechanism: entry.mechanism,
|
|
119
|
+
backup: entry.backup,
|
|
120
|
+
sha256Original: entry.sha256Original,
|
|
121
|
+
sha256Installed: sha256File(bin),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
out.push({ surface: id, ok: r.ok, detail: r.alreadyInstalled ? "already wrapped" : r.ok ? `wrapped ${bin}` : (r.reason ?? "failed") });
|
|
126
|
+
}
|
|
127
|
+
cfg.surfaces[id] = true;
|
|
128
|
+
}
|
|
129
|
+
saveConfig(cfg);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
function manifestHas(surface, target) {
|
|
133
|
+
return loadManifest().entries.some((e) => e.surface === surface && e.target === target);
|
|
134
|
+
}
|
|
135
|
+
export function uninstall(surfaces) {
|
|
136
|
+
const cfg = loadConfig();
|
|
137
|
+
const wanted = surfaces ?? Object.keys(cfg.surfaces).filter((k) => cfg.surfaces[k]);
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const id of wanted) {
|
|
140
|
+
const s = getSurface(id);
|
|
141
|
+
if (!s)
|
|
142
|
+
continue;
|
|
143
|
+
if (id === "claude") {
|
|
144
|
+
const r = uninstallStatusline();
|
|
145
|
+
if (r.ok && r.alreadyInstalled)
|
|
146
|
+
removeEntries(id);
|
|
147
|
+
out.push({ surface: id, ok: r.ok, detail: r.reason ?? (r.alreadyInstalled ? "restored" : "nothing to do") });
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
const bin = s.locate();
|
|
151
|
+
if (!bin) {
|
|
152
|
+
out.push({ surface: id, ok: false, detail: "CLI not found" });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// Integrity gate: never restore from a backup that fails its sha256 check.
|
|
156
|
+
const entry = loadManifest().entries.find((e) => e.surface === id && e.target === bin);
|
|
157
|
+
if (entry?.backup && entry.sha256Original && sha256File(entry.backup) !== entry.sha256Original) {
|
|
158
|
+
out.push({
|
|
159
|
+
surface: id,
|
|
160
|
+
ok: false,
|
|
161
|
+
detail: "REFUSED: backup fails sha256 check (tampered) — run `termad audit`; restore manually if needed",
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const r = uninstallWrapper(bin, paths.adCache());
|
|
166
|
+
if (r.ok && r.alreadyInstalled)
|
|
167
|
+
removeEntries(id);
|
|
168
|
+
out.push({ surface: id, ok: r.ok, detail: r.reason ?? (r.alreadyInstalled ? `restored ${bin}` : "nothing to do") });
|
|
169
|
+
}
|
|
170
|
+
cfg.surfaces[id] = false;
|
|
171
|
+
}
|
|
172
|
+
saveConfig(cfg);
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
/** Refresh the ad cache (used by `termad watch` and cron). */
|
|
176
|
+
export async function sync() {
|
|
177
|
+
const cfg = loadConfig();
|
|
178
|
+
const ad = await fetchAd("claude");
|
|
179
|
+
writeAdCache(ad);
|
|
180
|
+
writeFileAtomic(paths.adCache() + ".meta", JSON.stringify({ at: Date.now(), endpoint: cfg.endpoint }));
|
|
181
|
+
return ad.text;
|
|
182
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal renderers — the "reservation space".
|
|
3
|
+
*
|
|
4
|
+
* line: one line of wording (+ optional auto-linked URL)
|
|
5
|
+
* banner: small ANSI box — a "selected banner" that catches the eye
|
|
6
|
+
*
|
|
7
|
+
* All output is plain text + ANSI color. No images, no escape-sequence
|
|
8
|
+
* hyperlinks (OSC 8) — bare URLs let the terminal's own link detection
|
|
9
|
+
* do the clicking, which is the most portable path.
|
|
10
|
+
*/
|
|
11
|
+
declare const BOLD = "\u001B[1m";
|
|
12
|
+
export interface AdLines {
|
|
13
|
+
text: string;
|
|
14
|
+
url?: string;
|
|
15
|
+
sub?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function renderLine(ad: AdLines): string;
|
|
18
|
+
export declare function renderBanner(ad: AdLines): string;
|
|
19
|
+
export declare function render(ad: AdLines, style: "line" | "banner"): string;
|
|
20
|
+
export { BOLD };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal renderers — the "reservation space".
|
|
3
|
+
*
|
|
4
|
+
* line: one line of wording (+ optional auto-linked URL)
|
|
5
|
+
* banner: small ANSI box — a "selected banner" that catches the eye
|
|
6
|
+
*
|
|
7
|
+
* All output is plain text + ANSI color. No images, no escape-sequence
|
|
8
|
+
* hyperlinks (OSC 8) — bare URLs let the terminal's own link detection
|
|
9
|
+
* do the clicking, which is the most portable path.
|
|
10
|
+
*/
|
|
11
|
+
const RESET = "\x1b[0m";
|
|
12
|
+
const DIM = "\x1b[2m";
|
|
13
|
+
const BOLD = "\x1b[1m";
|
|
14
|
+
const CYAN = "\x1b[36m";
|
|
15
|
+
const YELLOW = "\x1b[33m";
|
|
16
|
+
export function renderLine(ad) {
|
|
17
|
+
const out = [`${DIM} [ad]${RESET} ${YELLOW}${ad.text}${RESET}`];
|
|
18
|
+
if (ad.url)
|
|
19
|
+
out.push(` ${CYAN}${ad.url}${RESET}`);
|
|
20
|
+
if (ad.sub)
|
|
21
|
+
out.push(` ${DIM}${ad.sub}${RESET}`);
|
|
22
|
+
return out.join("\n");
|
|
23
|
+
}
|
|
24
|
+
export function renderBanner(ad) {
|
|
25
|
+
const content = [ad.text, ...(ad.url ? [ad.url] : []), ...(ad.sub ? [ad.sub] : [])];
|
|
26
|
+
const width = Math.min(Math.max(...content.map((l) => visibleLen(l))), 56);
|
|
27
|
+
const bar = "─".repeat(width + 2);
|
|
28
|
+
const row = (s) => ` ${DIM}│${RESET} ${YELLOW}${pad(s, width)}${RESET} ${DIM}│${RESET}\n`;
|
|
29
|
+
let out = ` ${DIM}╭${bar}╮${RESET}\n`;
|
|
30
|
+
out += ` ${DIM}│${RESET} ${DIM}${pad("ad", width)}${RESET} ${DIM}│${RESET}\n`;
|
|
31
|
+
out += ` ${DIM}├${bar}┤${RESET}\n`;
|
|
32
|
+
for (const line of content)
|
|
33
|
+
out += row(line);
|
|
34
|
+
out += ` ${DIM}╰${bar}╯${RESET}`;
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
export function render(ad, style) {
|
|
38
|
+
return style === "banner" ? renderBanner(ad) : renderLine(ad);
|
|
39
|
+
}
|
|
40
|
+
/** Width ignoring ANSI codes. */
|
|
41
|
+
function visibleLen(s) {
|
|
42
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
43
|
+
}
|
|
44
|
+
function pad(s, width) {
|
|
45
|
+
return s + " ".repeat(Math.max(0, width - visibleLen(s)));
|
|
46
|
+
}
|
|
47
|
+
export { BOLD };
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Counts from the live log (used by tests / `termad status`). */
|
|
2
|
+
export declare function pendingDisplayCount(): number;
|
|
3
|
+
/**
|
|
4
|
+
* Report accumulated displays. Returns a human summary, or null when there
|
|
5
|
+
* is nothing to report or the endpoint is unreachable (counts are kept).
|
|
6
|
+
*/
|
|
7
|
+
export declare function reportDisplays(): Promise<string | null>;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch impression reporting: the wrapper appends one line of ad id to the
|
|
3
|
+
* display log per render; this reports the accumulated counts to the
|
|
4
|
+
* endpoint and credits the runner's 50% share in the ledger.
|
|
5
|
+
*
|
|
6
|
+
* No per-render network activity exists anywhere — the only "measurement"
|
|
7
|
+
* is counting what was actually printed to a terminal.
|
|
8
|
+
*/
|
|
9
|
+
import { appendFileSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
10
|
+
import { loadConfig } from "./core/config.js";
|
|
11
|
+
import { paths } from "./core/paths.js";
|
|
12
|
+
const PENDING = () => paths.displayLog() + ".sending";
|
|
13
|
+
const REPORT_TIMEOUT_MS = 5000;
|
|
14
|
+
/** Counts from the live log (used by tests / `termad status`). */
|
|
15
|
+
export function pendingDisplayCount() {
|
|
16
|
+
let raw = "";
|
|
17
|
+
try {
|
|
18
|
+
raw = readFileSync(paths.displayLog(), "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
return raw.split("\n").filter((l) => l.trim()).length;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Report accumulated displays. Returns a human summary, or null when there
|
|
27
|
+
* is nothing to report or the endpoint is unreachable (counts are kept).
|
|
28
|
+
*/
|
|
29
|
+
export async function reportDisplays() {
|
|
30
|
+
const cfg = loadConfig();
|
|
31
|
+
if (!cfg.endpoint)
|
|
32
|
+
return null;
|
|
33
|
+
// Recover a previous attempt that failed mid-flight (counts are never
|
|
34
|
+
// dropped silently; worst case a crash re-reports one batch once).
|
|
35
|
+
try {
|
|
36
|
+
const prev = readFileSync(PENDING(), "utf8");
|
|
37
|
+
if (prev)
|
|
38
|
+
appendFileSync(paths.displayLog(), prev);
|
|
39
|
+
rmSync(PENDING(), { force: true });
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = readFileSync(paths.displayLog(), "utf8");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const counts = {};
|
|
50
|
+
for (const line of raw.split("\n")) {
|
|
51
|
+
const id = line.trim();
|
|
52
|
+
if (id)
|
|
53
|
+
counts[id] = (counts[id] ?? 0) + 1;
|
|
54
|
+
}
|
|
55
|
+
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
56
|
+
if (!total)
|
|
57
|
+
return null;
|
|
58
|
+
// Snapshot before the POST so renders that happen mid-flight stay counted.
|
|
59
|
+
try {
|
|
60
|
+
renameSync(paths.displayLog(), PENDING());
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(`${cfg.endpoint.replace(/\/+$/, "")}/v1/report`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "content-type": "application/json" },
|
|
69
|
+
body: JSON.stringify({ uid: cfg.uid, counts }),
|
|
70
|
+
signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok)
|
|
73
|
+
return null;
|
|
74
|
+
const body = (await res.json());
|
|
75
|
+
rmSync(PENDING(), { force: true });
|
|
76
|
+
const share = typeof body.runnerEarnedMilliCents === "number"
|
|
77
|
+
? `, runner share +$${(body.runnerEarnedMilliCents / 1000 / 100).toFixed(4)}`
|
|
78
|
+
: "";
|
|
79
|
+
return `${total} display(s) reported${share}`;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Endpoint down → restore the snapshot so nothing is lost.
|
|
83
|
+
try {
|
|
84
|
+
const snap = readFileSync(PENDING(), "utf8");
|
|
85
|
+
appendFileSync(paths.displayLog(), snap);
|
|
86
|
+
rmSync(PENDING(), { force: true });
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface InlineResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
alreadyPatched?: boolean;
|
|
4
|
+
reason?: string;
|
|
5
|
+
mode?: "binary-patch" | "wrapper-fallback";
|
|
6
|
+
}
|
|
7
|
+
export declare const INLINE_MARKER = "===== TERMAD-INLINE =====";
|
|
8
|
+
export declare const INLINE_BACKUP_EXT = ".termad-bundle-orig";
|
|
9
|
+
export declare function inlineBackupPath(target: string): string;
|
|
10
|
+
export declare function isInlinePatched(target: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Attempt to patch a compiled binary to render ads inline in the TUI.
|
|
13
|
+
* Falls back gracefully if patching isn't possible.
|
|
14
|
+
*/
|
|
15
|
+
export declare function installInline(target: string): InlineResult;
|
|
16
|
+
export declare function uninstallInline(target: string): InlineResult;
|
|
17
|
+
export declare function isCompiledBinary(p: string): boolean;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline (in-TUI) patching for compiled CLIs.
|
|
3
|
+
*
|
|
4
|
+
* Goal: inject a tiny footer renderer into the CLI's TUI so the ad appears
|
|
5
|
+
* inside the UI (like Kickbacks did), not above it.
|
|
6
|
+
*
|
|
7
|
+
* Strategy:
|
|
8
|
+
* - Detect Bun/Node compiled single-file binaries (Mach-O/ELF) by magic bytes
|
|
9
|
+
* - Keep pristine original in <bin>.termad-bundle-orig (separate from wrapper backup)
|
|
10
|
+
* - Search for Ink/render footers or stable strings and inject a loader that
|
|
11
|
+
* reads ~/.termad/ad.txt and renders "[ad] ..." as a subtle footer line
|
|
12
|
+
* - Fully reversible via uninstall + audit tracking
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { copyFileSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { WRAPPER_MARKER } from "../core/paths.js";
|
|
17
|
+
export const INLINE_MARKER = "===== TERMAD-INLINE =====";
|
|
18
|
+
export const INLINE_BACKUP_EXT = ".termad-bundle-orig";
|
|
19
|
+
export function inlineBackupPath(target) {
|
|
20
|
+
return target + INLINE_BACKUP_EXT;
|
|
21
|
+
}
|
|
22
|
+
export function isInlinePatched(target) {
|
|
23
|
+
try {
|
|
24
|
+
const buf = readFileSync(target);
|
|
25
|
+
// Quick check: look for our marker string in the binary
|
|
26
|
+
return buf.indexOf(Buffer.from(INLINE_MARKER)) !== -1;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function isMachO(p) {
|
|
33
|
+
try {
|
|
34
|
+
const buf = readFileSync(p);
|
|
35
|
+
if (buf.length < 4)
|
|
36
|
+
return false;
|
|
37
|
+
// fat binary or 64-bit little/big endian
|
|
38
|
+
const m = buf.readUInt32BE(0);
|
|
39
|
+
if (m === 0xcafebabe || m === 0xbebafeca)
|
|
40
|
+
return true;
|
|
41
|
+
const m2 = buf.readUInt32LE(0);
|
|
42
|
+
if (m2 === 0xfeedfacf || m2 === 0xcffaedfe)
|
|
43
|
+
return true;
|
|
44
|
+
if (m === 0xfeedface || m === 0xcefaedfe)
|
|
45
|
+
return true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function isElf(p) {
|
|
53
|
+
try {
|
|
54
|
+
const buf = readFileSync(p);
|
|
55
|
+
return buf.length >= 4 && buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function sha256(p) {
|
|
62
|
+
try {
|
|
63
|
+
return createHash("sha256").update(readFileSync(p)).digest("hex");
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Minimal inline ad loader injected into embedded bundle
|
|
70
|
+
// Tries to read ~/.termad/ad.txt and expose a global for TUI to render
|
|
71
|
+
const INLINE_PAYLOAD = `/* ${INLINE_MARKER} */;(()=>{try{const fs=require('fs');const os=require('os');const p=require('path');const home=process.env.TERMAD_HOME||p.join(os.homedir(),'.termad');const f=p.join(home,'ad.txt');const c=fs.readFileSync(f,'utf8');const lines=c.replace(/\\r/g,'').split('\\n');const text=(lines[0]||'').trim();const url=(lines[1]||'').trim();if(text&&text!=='-'){globalThis.__TERMAD_AD__={text,url:url&&url!=='-'?url:undefined,at:Date.now()};}}catch(e){}})();`;
|
|
72
|
+
/**
|
|
73
|
+
* Attempt to patch a compiled binary to render ads inline in the TUI.
|
|
74
|
+
* Falls back gracefully if patching isn't possible.
|
|
75
|
+
*/
|
|
76
|
+
export function installInline(target) {
|
|
77
|
+
if (!existsSync(target))
|
|
78
|
+
return { ok: false, reason: `target not found: ${target}` };
|
|
79
|
+
if (isInlinePatched(target))
|
|
80
|
+
return { ok: true, alreadyPatched: true, mode: "binary-patch" };
|
|
81
|
+
if (isWrappedCheck(target)) {
|
|
82
|
+
// Don't fight the shell wrapper - we'll upgrade it instead
|
|
83
|
+
return { ok: false, reason: "has termad wrapper; use inline upgrade" };
|
|
84
|
+
}
|
|
85
|
+
const bak = inlineBackupPath(target);
|
|
86
|
+
if (!existsSync(bak)) {
|
|
87
|
+
try {
|
|
88
|
+
copyFileSync(target, bak);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
return { ok: false, reason: e.message };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// For now, try a conservative string injection approach
|
|
95
|
+
// Look for common Ink patterns and inject our loader
|
|
96
|
+
try {
|
|
97
|
+
let buf = readFileSync(target);
|
|
98
|
+
let patched = false;
|
|
99
|
+
// Try to inject near module load points
|
|
100
|
+
const injectPoints = [
|
|
101
|
+
Buffer.from("use strict"),
|
|
102
|
+
Buffer.from("var React"),
|
|
103
|
+
Buffer.from('"use strict"'),
|
|
104
|
+
];
|
|
105
|
+
for (const pt of injectPoints) {
|
|
106
|
+
const idx = buf.indexOf(pt);
|
|
107
|
+
if (idx !== -1 && idx < 2048) {
|
|
108
|
+
// Insert our payload after the first occurrence
|
|
109
|
+
const payload = Buffer.from(INLINE_PAYLOAD + "\n");
|
|
110
|
+
buf = Buffer.concat([buf.slice(0, idx), payload, buf.slice(idx)]);
|
|
111
|
+
patched = true;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!patched) {
|
|
116
|
+
// Try appending near beginning of embedded JS
|
|
117
|
+
// Many Bun binaries have a loader section
|
|
118
|
+
const payload = Buffer.from(INLINE_PAYLOAD + "\n");
|
|
119
|
+
// Insert after first 256 bytes
|
|
120
|
+
buf = Buffer.concat([buf.slice(0, 512), payload, buf.slice(512)]);
|
|
121
|
+
patched = true;
|
|
122
|
+
}
|
|
123
|
+
if (patched) {
|
|
124
|
+
const mode = statSync(target).mode & 0o777;
|
|
125
|
+
writeFileSync(target, buf);
|
|
126
|
+
try {
|
|
127
|
+
// Restore permissions
|
|
128
|
+
if (!target.endsWith(".cmd") && !target.endsWith(".bat")) {
|
|
129
|
+
// chmod if possible
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch { }
|
|
133
|
+
return { ok: true, mode: "binary-patch" };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
return { ok: false, reason: e.message };
|
|
138
|
+
}
|
|
139
|
+
return { ok: false, reason: "could not find injection point" };
|
|
140
|
+
}
|
|
141
|
+
function isWrappedCheck(target) {
|
|
142
|
+
try {
|
|
143
|
+
const head = readFileSync(target, "utf8").slice(0, 256);
|
|
144
|
+
return head.includes(WRAPPER_MARKER);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export function uninstallInline(target) {
|
|
151
|
+
const bak = inlineBackupPath(target);
|
|
152
|
+
if (!existsSync(bak)) {
|
|
153
|
+
return { ok: true, alreadyPatched: false, reason: "no inline backup found" };
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
if (existsSync(target) && isInlinePatched(target)) {
|
|
157
|
+
const mode = statSync(bak).mode & 0o777;
|
|
158
|
+
copyFileSync(bak, target);
|
|
159
|
+
rmSync(bak);
|
|
160
|
+
return { ok: true };
|
|
161
|
+
}
|
|
162
|
+
rmSync(bak);
|
|
163
|
+
return { ok: true, reason: "target not patched inline; backup removed" };
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
return { ok: false, reason: e.message };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
export function isCompiledBinary(p) {
|
|
170
|
+
return isMachO(p) || isElf(p);
|
|
171
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runWithAdOverlay(binary: string, args?: string[]): Promise<void>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const TERMAD_HOME = process.env.TERMAD_HOME || join(homedir(), ".termad");
|
|
5
|
+
const AD_FILE = join(TERMAD_HOME, "ad.txt");
|
|
6
|
+
const COUNT_FILE = join(TERMAD_HOME, "display.log");
|
|
7
|
+
function readAd() {
|
|
8
|
+
try {
|
|
9
|
+
const raw = readFileSync(AD_FILE, "utf8").replace(/\r/g, "").split("\n");
|
|
10
|
+
const text = (raw[0] || "").trim();
|
|
11
|
+
const url = (raw[1] || "").trim();
|
|
12
|
+
const id = (raw[3] || "").trim();
|
|
13
|
+
if (!text || text === "-")
|
|
14
|
+
return null;
|
|
15
|
+
return { text, url: url && url !== "-" ? url : undefined, id: id && id !== "-" ? id : undefined };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function count(id) {
|
|
22
|
+
if (!id)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
appendFileSync(COUNT_FILE, id + "\n");
|
|
26
|
+
}
|
|
27
|
+
catch { }
|
|
28
|
+
}
|
|
29
|
+
function buildFooter(ad) {
|
|
30
|
+
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`;
|
|
36
|
+
return line;
|
|
37
|
+
}
|
|
38
|
+
export function runWithAdOverlay(binary, args = []) {
|
|
39
|
+
return runWithAdOverlayAsync(binary, args);
|
|
40
|
+
}
|
|
41
|
+
async function runWithAdOverlayAsync(binary, args = []) {
|
|
42
|
+
// Try to use node-pty if available
|
|
43
|
+
let pty;
|
|
44
|
+
try {
|
|
45
|
+
// @ts-ignore
|
|
46
|
+
pty = await import("node-pty");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
try {
|
|
50
|
+
// @ts-ignore
|
|
51
|
+
pty = await import("@lydell/node-pty");
|
|
52
|
+
}
|
|
53
|
+
catch { }
|
|
54
|
+
}
|
|
55
|
+
if (!pty) {
|
|
56
|
+
// No PTY available - fall back to printing banner then execing
|
|
57
|
+
const ad = readAd();
|
|
58
|
+
if (ad) {
|
|
59
|
+
process.stdout.write("\n [ad] " + ad.text + "\n");
|
|
60
|
+
if (ad.url)
|
|
61
|
+
process.stdout.write(" " + ad.url + "\n\n");
|
|
62
|
+
count(ad.id);
|
|
63
|
+
}
|
|
64
|
+
const { spawn } = await import("node:child_process");
|
|
65
|
+
const child = spawn(binary, args, { stdio: "inherit" });
|
|
66
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const ad = readAd();
|
|
70
|
+
if (ad?.id)
|
|
71
|
+
count(ad.id);
|
|
72
|
+
const cols = process.stdout.columns || 120;
|
|
73
|
+
const rows = process.stdout.rows || 40;
|
|
74
|
+
const ptyProcess = pty.spawn(binary, args, {
|
|
75
|
+
name: "xterm-256color",
|
|
76
|
+
cols,
|
|
77
|
+
rows,
|
|
78
|
+
cwd: process.cwd(),
|
|
79
|
+
env: process.env,
|
|
80
|
+
});
|
|
81
|
+
let footerVisible = !!ad;
|
|
82
|
+
let lastAd = ad;
|
|
83
|
+
function drawFooter() {
|
|
84
|
+
if (!footerVisible || !lastAd)
|
|
85
|
+
return;
|
|
86
|
+
const footer = buildFooter(lastAd);
|
|
87
|
+
// Move to bottom row, clear line, print footer
|
|
88
|
+
const termRows = ptyProcess.rows || rows;
|
|
89
|
+
process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K${footer}\x1b[u`);
|
|
90
|
+
}
|
|
91
|
+
function hideFooter() {
|
|
92
|
+
const termRows = ptyProcess.rows || rows;
|
|
93
|
+
process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K\x1b[u`);
|
|
94
|
+
}
|
|
95
|
+
// Pipe PTY output to stdout with footer redrawn after each chunk
|
|
96
|
+
ptyProcess.onData((data) => {
|
|
97
|
+
process.stdout.write(data);
|
|
98
|
+
// Redraw footer after a short delay to stay on top
|
|
99
|
+
setTimeout(drawFooter, 10);
|
|
100
|
+
});
|
|
101
|
+
// Pipe stdin to PTY
|
|
102
|
+
if (process.stdin.isTTY) {
|
|
103
|
+
process.stdin.setRawMode(true);
|
|
104
|
+
}
|
|
105
|
+
process.stdin.on("data", (data) => {
|
|
106
|
+
ptyProcess.write(data);
|
|
107
|
+
});
|
|
108
|
+
// Handle resize
|
|
109
|
+
process.stdout.on("resize", () => {
|
|
110
|
+
const c = process.stdout.columns || cols;
|
|
111
|
+
const r = process.stdout.rows || rows;
|
|
112
|
+
try {
|
|
113
|
+
ptyProcess.resize(c, r);
|
|
114
|
+
}
|
|
115
|
+
catch { }
|
|
116
|
+
setTimeout(drawFooter, 50);
|
|
117
|
+
});
|
|
118
|
+
// Handle exit
|
|
119
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
120
|
+
if (process.stdin.isTTY) {
|
|
121
|
+
try {
|
|
122
|
+
process.stdin.setRawMode(false);
|
|
123
|
+
}
|
|
124
|
+
catch { }
|
|
125
|
+
}
|
|
126
|
+
process.stdin.pause();
|
|
127
|
+
hideFooter();
|
|
128
|
+
process.exit(exitCode ?? 0);
|
|
129
|
+
});
|
|
130
|
+
// Initial draw
|
|
131
|
+
setTimeout(drawFooter, 100);
|
|
132
|
+
setInterval(drawFooter, 500); // Keep footer visible on redraws
|
|
133
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function settingsPath(): string;
|
|
2
|
+
export interface StatuslineInstallResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
alreadyInstalled?: boolean;
|
|
5
|
+
reason?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function installStatusline(): StatuslineInstallResult;
|
|
8
|
+
export declare function uninstallStatusline(): StatuslineInstallResult;
|
|
9
|
+
export declare function isStatuslineInstalled(): boolean;
|