@expitc/termad 0.0.0-stage → 0.1.0
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/wrapper.cmd.asset +31 -0
- package/assets/wrapper.sh.asset +29 -0
- package/dist/assets/wrapper.cmd.asset +31 -0
- package/dist/assets/wrapper.sh.asset +29 -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 +23 -0
- package/dist/core/paths.js +29 -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/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 +108 -0
- package/package.json +49 -4
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { paths, termadHome } from "./paths.js";
|
|
4
|
+
export const ALL_SURFACES = ["claude", "codex", "opencode", "gemini"];
|
|
5
|
+
function defaultConfig() {
|
|
6
|
+
return {
|
|
7
|
+
endpoint: null,
|
|
8
|
+
uid: crypto.randomUUID(),
|
|
9
|
+
pollSeconds: 900,
|
|
10
|
+
surfaces: { claude: false, codex: false, opencode: false, gemini: false },
|
|
11
|
+
style: "line",
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function loadConfig() {
|
|
15
|
+
try {
|
|
16
|
+
const raw = JSON.parse(readFileSync(paths.config(), "utf8"));
|
|
17
|
+
return { ...defaultConfig(), ...raw, surfaces: { ...defaultConfig().surfaces, ...raw.surfaces } };
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return defaultConfig();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function saveConfig(cfg) {
|
|
24
|
+
mkdirSync(termadHome(), { recursive: true });
|
|
25
|
+
writeFileSync(paths.config(), JSON.stringify(cfg, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
/** Atomic-ish write so wrappers never read a half-written cache. */
|
|
28
|
+
export function writeFileAtomic(path, data) {
|
|
29
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
30
|
+
const tmp = path + ".tmp";
|
|
31
|
+
writeFileSync(tmp, data);
|
|
32
|
+
renameSync(tmp, path);
|
|
33
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface ManifestEntry {
|
|
2
|
+
surface: string;
|
|
3
|
+
target: string;
|
|
4
|
+
mechanism: "wrapper" | "statusline";
|
|
5
|
+
/** Backup holding the pristine original (null if none was needed). */
|
|
6
|
+
backup: string | null;
|
|
7
|
+
sha256Original: string | null;
|
|
8
|
+
sha256Installed: string | null;
|
|
9
|
+
at: string;
|
|
10
|
+
}
|
|
11
|
+
export interface Manifest {
|
|
12
|
+
version: 1;
|
|
13
|
+
entries: ManifestEntry[];
|
|
14
|
+
}
|
|
15
|
+
export declare function sha256File(p: string): string | null;
|
|
16
|
+
export declare function loadManifest(): Manifest;
|
|
17
|
+
export declare function saveManifest(m: Manifest): void;
|
|
18
|
+
/** Record (or replace) the entry for one surface+target pair. */
|
|
19
|
+
export declare function recordEntry(e: Omit<ManifestEntry, "at">): void;
|
|
20
|
+
export declare function removeEntries(surface: string): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tamper-evident audit manifest: every change termad makes is recorded with
|
|
3
|
+
* sha256 fingerprints of the original and the installed file. `termad audit`
|
|
4
|
+
* re-verifies the chain, so any modification — by termad, the user, the CLI
|
|
5
|
+
* itself, or a third party — is attributable.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { paths } from "./paths.js";
|
|
10
|
+
import { writeFileAtomic } from "./config.js";
|
|
11
|
+
export function sha256File(p) {
|
|
12
|
+
try {
|
|
13
|
+
return createHash("sha256").update(readFileSync(p)).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function loadManifest() {
|
|
20
|
+
try {
|
|
21
|
+
const m = JSON.parse(readFileSync(paths.manifest(), "utf8"));
|
|
22
|
+
if (m?.version === 1 && Array.isArray(m.entries))
|
|
23
|
+
return m;
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
return { version: 1, entries: [] };
|
|
27
|
+
}
|
|
28
|
+
export function saveManifest(m) {
|
|
29
|
+
writeFileAtomic(paths.manifest(), JSON.stringify(m, null, 2) + "\n");
|
|
30
|
+
}
|
|
31
|
+
/** Record (or replace) the entry for one surface+target pair. */
|
|
32
|
+
export function recordEntry(e) {
|
|
33
|
+
const m = loadManifest();
|
|
34
|
+
m.entries = m.entries.filter((x) => !(x.surface === e.surface && x.target === e.target));
|
|
35
|
+
m.entries.push({ ...e, at: new Date().toISOString() });
|
|
36
|
+
saveManifest(m);
|
|
37
|
+
}
|
|
38
|
+
export function removeEntries(surface) {
|
|
39
|
+
const m = loadManifest();
|
|
40
|
+
m.entries = m.entries.filter((x) => x.surface !== surface);
|
|
41
|
+
saveManifest(m);
|
|
42
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** All termad state lives under ~/.termad (override with TERMAD_HOME). */
|
|
2
|
+
export declare function termadHome(): string;
|
|
3
|
+
export declare const paths: {
|
|
4
|
+
config: () => string;
|
|
5
|
+
/** Cache file the wrappers read: line 1 = text, line 2 = url, line 3 = sub. */
|
|
6
|
+
adCache: () => string;
|
|
7
|
+
/** Claude statusline launcher installed into ~/.claude/settings.json. */
|
|
8
|
+
statuslineScript: () => string;
|
|
9
|
+
/** Captured pre-install statusLine (chain capture, like claude does natively). */
|
|
10
|
+
prevStatusline: () => string;
|
|
11
|
+
log: () => string;
|
|
12
|
+
/** Tamper-evident record of every change (`termad audit` re-verifies it). */
|
|
13
|
+
manifest: () => string;
|
|
14
|
+
/**
|
|
15
|
+
* Display counter: one line of ad id per render, appended by the wrapper,
|
|
16
|
+
* reported in batches by `termad sync` (never per-render — no network on
|
|
17
|
+
* the CLI hot path, no per-view beacon).
|
|
18
|
+
*/
|
|
19
|
+
displayLog: () => string;
|
|
20
|
+
};
|
|
21
|
+
/** Wrapper backup for a binary/shim lives next to it: <bin>.termad-orig. */
|
|
22
|
+
export declare function backupPath(target: string): string;
|
|
23
|
+
export declare const WRAPPER_MARKER = "===== TERMAD-CLI =====";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** All termad state lives under ~/.termad (override with TERMAD_HOME). */
|
|
4
|
+
export function termadHome() {
|
|
5
|
+
return process.env.TERMAD_HOME || join(homedir(), ".termad");
|
|
6
|
+
}
|
|
7
|
+
export const paths = {
|
|
8
|
+
config: () => join(termadHome(), "config.json"),
|
|
9
|
+
/** Cache file the wrappers read: line 1 = text, line 2 = url, line 3 = sub. */
|
|
10
|
+
adCache: () => join(termadHome(), "ad.txt"),
|
|
11
|
+
/** Claude statusline launcher installed into ~/.claude/settings.json. */
|
|
12
|
+
statuslineScript: () => join(termadHome(), "statusline.mjs"),
|
|
13
|
+
/** Captured pre-install statusLine (chain capture, like claude does natively). */
|
|
14
|
+
prevStatusline: () => join(termadHome(), "prev-statusline.json"),
|
|
15
|
+
log: () => join(termadHome(), "termad.log"),
|
|
16
|
+
/** Tamper-evident record of every change (`termad audit` re-verifies it). */
|
|
17
|
+
manifest: () => join(termadHome(), "manifest.json"),
|
|
18
|
+
/**
|
|
19
|
+
* Display counter: one line of ad id per render, appended by the wrapper,
|
|
20
|
+
* reported in batches by `termad sync` (never per-render — no network on
|
|
21
|
+
* the CLI hot path, no per-view beacon).
|
|
22
|
+
*/
|
|
23
|
+
displayLog: () => join(termadHome(), "display.log"),
|
|
24
|
+
};
|
|
25
|
+
/** Wrapper backup for a binary/shim lives next to it: <bin>.termad-orig. */
|
|
26
|
+
export function backupPath(target) {
|
|
27
|
+
return target + ".termad-orig";
|
|
28
|
+
}
|
|
29
|
+
export const WRAPPER_MARKER = "===== TERMAD-CLI =====";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Ad } from "./ad.js";
|
|
2
|
+
import { type SurfaceId } from "./config.js";
|
|
3
|
+
/** Last-good ad survives endpoint outages (write-through cache). */
|
|
4
|
+
export declare function fetchAd(surface: SurfaceId): Promise<Ad>;
|
|
5
|
+
/**
|
|
6
|
+
* Write the ad cache the wrappers read:
|
|
7
|
+
* line 1 = wording, line 2 = click URL, line 3 = sub, line 4 = ad id.
|
|
8
|
+
* Lines 2/3 are padded with "-" when absent so line-numbered readers
|
|
9
|
+
* (including cmd's `for /f`, which skips blank lines) stay positional.
|
|
10
|
+
* Wrappers only ever read this file — zero network in the hot path.
|
|
11
|
+
* Line 4 (ad id) is what the wrapper appends to the display counter.
|
|
12
|
+
*/
|
|
13
|
+
export declare function writeAdCache(ad: Ad): void;
|
|
14
|
+
export declare function readAdCache(): {
|
|
15
|
+
text: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
sub?: string;
|
|
18
|
+
} | null;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { isValidAd } from "./ad.js";
|
|
3
|
+
import { loadConfig, writeFileAtomic } from "./config.js";
|
|
4
|
+
import { paths, termadHome } from "./paths.js";
|
|
5
|
+
const FETCH_TIMEOUT_MS = 4000;
|
|
6
|
+
/** Fallback ad if nothing is configured or the endpoint is down. */
|
|
7
|
+
const HOUSE_AD = { id: "house:default", text: "termad — ads for your terminal" };
|
|
8
|
+
/** Last-good ad survives endpoint outages (write-through cache). */
|
|
9
|
+
export async function fetchAd(surface) {
|
|
10
|
+
const cfg = loadConfig();
|
|
11
|
+
if (cfg.endpoint) {
|
|
12
|
+
try {
|
|
13
|
+
const url = `${cfg.endpoint.replace(/\/+$/, "")}/v1/ad?surface=${surface}&uid=${cfg.uid}`;
|
|
14
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
15
|
+
if (res.ok) {
|
|
16
|
+
const body = (await res.json());
|
|
17
|
+
if (isValidAd(body.ad)) {
|
|
18
|
+
writeLastGood(body.ad);
|
|
19
|
+
return body.ad;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// fall through to cache / house ad — never break the CLI over an ad
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return lastGood() ?? HOUSE_AD;
|
|
28
|
+
}
|
|
29
|
+
function lastGoodPath() {
|
|
30
|
+
return paths.adCache() + ".json";
|
|
31
|
+
}
|
|
32
|
+
function writeLastGood(ad) {
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(termadHome(), { recursive: true });
|
|
35
|
+
writeFileSync(lastGoodPath(), JSON.stringify(ad));
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
}
|
|
39
|
+
function lastGood() {
|
|
40
|
+
try {
|
|
41
|
+
const ad = JSON.parse(readFileSync(lastGoodPath(), "utf8"));
|
|
42
|
+
return isValidAd(ad) ? ad : null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Write the ad cache the wrappers read:
|
|
50
|
+
* line 1 = wording, line 2 = click URL, line 3 = sub, line 4 = ad id.
|
|
51
|
+
* Lines 2/3 are padded with "-" when absent so line-numbered readers
|
|
52
|
+
* (including cmd's `for /f`, which skips blank lines) stay positional.
|
|
53
|
+
* Wrappers only ever read this file — zero network in the hot path.
|
|
54
|
+
* Line 4 (ad id) is what the wrapper appends to the display counter.
|
|
55
|
+
*/
|
|
56
|
+
export function writeAdCache(ad) {
|
|
57
|
+
const lines = [
|
|
58
|
+
ad.text.replace(/[\r\n]+/g, " ").trim(),
|
|
59
|
+
ad.url ?? "-",
|
|
60
|
+
ad.sub ? ad.sub.replace(/[\r\n]+/g, " ").trim() : "-",
|
|
61
|
+
ad.id || "-",
|
|
62
|
+
];
|
|
63
|
+
writeFileAtomic(paths.adCache(), lines.join("\n") + "\n");
|
|
64
|
+
}
|
|
65
|
+
export function readAdCache() {
|
|
66
|
+
try {
|
|
67
|
+
const raw = readFileSync(paths.adCache(), "utf8").split("\n");
|
|
68
|
+
const text = (raw[0] ?? "").trim();
|
|
69
|
+
if (!text)
|
|
70
|
+
return null;
|
|
71
|
+
const url = (raw[1] ?? "").trim();
|
|
72
|
+
const sub = (raw[2] ?? "").trim();
|
|
73
|
+
return { text, url: url && url !== "-" ? url : undefined, sub: sub && sub !== "-" ? sub : undefined };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { type Ad, isValidAd } from "./core/ad.js";
|
|
2
|
+
export { fetchAd, writeAdCache, readAdCache } from "./core/source.js";
|
|
3
|
+
export { loadConfig, saveConfig, type SurfaceId, type TermadConfig } from "./core/config.js";
|
|
4
|
+
export { install, uninstall, sync } from "./install.js";
|
|
5
|
+
export { render, renderLine, renderBanner } from "./render/render.js";
|
|
6
|
+
export { installWrapper, uninstallWrapper, isWrapped, locateCli } from "./surfaces/wrap.js";
|
|
7
|
+
export { installStatusline, uninstallStatusline, isStatuslineInstalled } from "./surfaces/statusline.js";
|
|
8
|
+
export { surfaces, getSurface, type Surface } from "./surfaces/surfaces.js";
|
|
9
|
+
export { paths, termadHome, backupPath } from "./core/paths.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { isValidAd } from "./core/ad.js";
|
|
2
|
+
export { fetchAd, writeAdCache, readAdCache } from "./core/source.js";
|
|
3
|
+
export { loadConfig, saveConfig } from "./core/config.js";
|
|
4
|
+
export { install, uninstall, sync } from "./install.js";
|
|
5
|
+
export { render, renderLine, renderBanner } from "./render/render.js";
|
|
6
|
+
export { installWrapper, uninstallWrapper, isWrapped, locateCli } from "./surfaces/wrap.js";
|
|
7
|
+
export { installStatusline, uninstallStatusline, isStatuslineInstalled } from "./surfaces/statusline.js";
|
|
8
|
+
export { surfaces, getSurface } from "./surfaces/surfaces.js";
|
|
9
|
+
export { paths, termadHome, backupPath } from "./core/paths.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
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 { type SurfaceId } from "./core/config.js";
|
|
8
|
+
export interface SurfaceOutcome {
|
|
9
|
+
surface: SurfaceId;
|
|
10
|
+
ok: boolean;
|
|
11
|
+
detail: string;
|
|
12
|
+
}
|
|
13
|
+
export interface PlanItem {
|
|
14
|
+
surface: SurfaceId;
|
|
15
|
+
target: string;
|
|
16
|
+
action: string;
|
|
17
|
+
}
|
|
18
|
+
/** What `termad install` would change — shown to the user before consent. */
|
|
19
|
+
export declare function planInstall(surfaces?: SurfaceId[], force?: boolean): PlanItem[];
|
|
20
|
+
/** Fetch the ad, write the cache, then install the opted-in surfaces. */
|
|
21
|
+
export declare function install(surfaces?: SurfaceId[], opts?: {
|
|
22
|
+
force?: boolean;
|
|
23
|
+
}): Promise<SurfaceOutcome[]>;
|
|
24
|
+
export declare function uninstall(surfaces?: SurfaceId[]): SurfaceOutcome[];
|
|
25
|
+
/** Refresh the ad cache (used by `termad watch` and cron). */
|
|
26
|
+
export declare function sync(): Promise<string>;
|
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,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;
|