@hanamorilabs/tab 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/README.md +67 -0
- package/dist/cli.js +611 -0
- package/dist/clients.js +103 -0
- package/dist/codex-home.js +49 -0
- package/dist/config.js +123 -0
- package/dist/console-api.js +34 -0
- package/dist/device-login.js +120 -0
- package/dist/folder.js +47 -0
- package/dist/project.js +75 -0
- package/dist/proxy-bin.js +216 -0
- package/dist/selfhost.js +33 -0
- package/dist/ui.js +99 -0
- package/package.json +54 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The self-hosted proxy is one static binary (`flocktab-proxy`, Rust, see
|
|
3
|
+
* apps/proxy-rs). It arrives with `npm i -g @hanamorilabs/tab` as the
|
|
4
|
+
* platform package for this machine (an optional dependency); when that is
|
|
5
|
+
* missing, `tab up` fetches the build from the GitHub release into
|
|
6
|
+
* ~/.flocktab/bin. Either way it runs as a background process with the
|
|
7
|
+
* provider keys from ~/.flocktab/proxy.env. No Docker, no checkout, no
|
|
8
|
+
* runtime beyond the binary itself.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { configDir } from "./config.js";
|
|
15
|
+
/** The proxy release `tab up` fetches. Bump with each proxy tag. */
|
|
16
|
+
export const PROXY_VERSION = "0.1.1";
|
|
17
|
+
export const RELEASES = "https://github.com/joseairosa/flocktab/releases/download";
|
|
18
|
+
export function targetFor(platform = process.platform, arch = process.arch) {
|
|
19
|
+
if (platform === "darwin")
|
|
20
|
+
return arch === "arm64" ? "darwin-arm64" : arch === "x64" ? "darwin-x64" : undefined;
|
|
21
|
+
if (platform === "linux")
|
|
22
|
+
return arch === "x64" ? "linux-x64" : arch === "arm64" ? "linux-arm64" : undefined;
|
|
23
|
+
if (platform === "win32")
|
|
24
|
+
return arch === "x64" ? "win-x64" : undefined;
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
export function assetUrl(version, target) {
|
|
28
|
+
const ext = target === "win-x64" ? ".exe" : "";
|
|
29
|
+
return `${RELEASES}/proxy-v${version}/flocktab-proxy-${target}${ext}`;
|
|
30
|
+
}
|
|
31
|
+
/** Where a download lands when no platform package is installed. */
|
|
32
|
+
export function downloadedBinPath(env = process.env) {
|
|
33
|
+
return path.join(configDir(env), "bin", process.platform === "win32" ? "flocktab-proxy.exe" : "flocktab-proxy");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The binary that ships with `npm i -g @hanamorilabs/tab`: the platform
|
|
37
|
+
* package listed as an optional dependency. Resolved from this module, so
|
|
38
|
+
* a global install finds it beside `tab` in the global node_modules.
|
|
39
|
+
*/
|
|
40
|
+
export function packagedBinPath(target = targetFor()) {
|
|
41
|
+
if (!target)
|
|
42
|
+
return undefined;
|
|
43
|
+
const bin = target === "win-x64" ? "flocktab-proxy.exe" : "flocktab-proxy";
|
|
44
|
+
try {
|
|
45
|
+
return createRequire(import.meta.url).resolve(`@hanamorilabs/flocktab-proxy-${target}/${bin}`);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** FLOCKTAB_PROXY_BIN, else the packaged binary, else a download under ~/.flocktab/bin. */
|
|
52
|
+
export function proxyBinPath(env = process.env) {
|
|
53
|
+
const override = env.FLOCKTAB_PROXY_BIN?.trim();
|
|
54
|
+
if (override)
|
|
55
|
+
return override;
|
|
56
|
+
return packagedBinPath() ?? downloadedBinPath(env);
|
|
57
|
+
}
|
|
58
|
+
export function proxyEnvPath(env = process.env) {
|
|
59
|
+
return path.join(configDir(env), "proxy.env");
|
|
60
|
+
}
|
|
61
|
+
export function proxyLogPath(env = process.env) {
|
|
62
|
+
return path.join(configDir(env), "proxy.log");
|
|
63
|
+
}
|
|
64
|
+
function pidPath(env) {
|
|
65
|
+
return path.join(configDir(env), "proxy.pid");
|
|
66
|
+
}
|
|
67
|
+
async function exists(file) {
|
|
68
|
+
try {
|
|
69
|
+
await stat(file);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function hasProxyEnv(env = process.env) {
|
|
77
|
+
return exists(proxyEnvPath(env));
|
|
78
|
+
}
|
|
79
|
+
/** KEY=VALUE lines; comments and blanks skipped; no shell quoting. */
|
|
80
|
+
export function parseEnvFile(text) {
|
|
81
|
+
const out = {};
|
|
82
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
83
|
+
const line = raw.trim();
|
|
84
|
+
if (!line || line.startsWith("#"))
|
|
85
|
+
continue;
|
|
86
|
+
const eq = line.indexOf("=");
|
|
87
|
+
if (eq <= 0)
|
|
88
|
+
continue;
|
|
89
|
+
out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
export async function readProxyEnv(env = process.env) {
|
|
94
|
+
try {
|
|
95
|
+
return parseEnvFile(await readFile(proxyEnvPath(env), "utf8"));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The provider keys the proxy spends: plaintext on purpose, this is the
|
|
103
|
+
* operator's own machine, the same trade as any `.env`. Owner-only.
|
|
104
|
+
*/
|
|
105
|
+
export async function writeProxyEnv(input, env = process.env) {
|
|
106
|
+
const dir = configDir(env);
|
|
107
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
108
|
+
const lines = [
|
|
109
|
+
`# FlockTab self-hosted proxy. Keys stay here; the ledger is ${input.control}.`,
|
|
110
|
+
`FLOCKTAB_CONTROL_URL=${input.control}`,
|
|
111
|
+
...Object.entries(input.keys)
|
|
112
|
+
.filter(([, v]) => v.trim())
|
|
113
|
+
.map(([id, v]) => `FLOCKTAB_PROVIDER_KEY_${id.toUpperCase()}=${v.trim()}`),
|
|
114
|
+
"",
|
|
115
|
+
];
|
|
116
|
+
const file = proxyEnvPath(env);
|
|
117
|
+
await writeFile(file, lines.join("\n"), { mode: 0o600 });
|
|
118
|
+
await chmod(file, 0o600);
|
|
119
|
+
return file;
|
|
120
|
+
}
|
|
121
|
+
/** Fetch this machine's build of the proxy from the release. */
|
|
122
|
+
export async function downloadProxy(log, env = process.env, fetchImpl = fetch) {
|
|
123
|
+
const target = targetFor();
|
|
124
|
+
if (!target)
|
|
125
|
+
return { ok: false, message: `no proxy build for ${process.platform}/${process.arch}` };
|
|
126
|
+
const url = assetUrl(PROXY_VERSION, target);
|
|
127
|
+
const file = downloadedBinPath(env);
|
|
128
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
129
|
+
log(`Fetching flocktab-proxy ${PROXY_VERSION} for ${target}`);
|
|
130
|
+
let res;
|
|
131
|
+
try {
|
|
132
|
+
res = await fetchImpl(url);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
return { ok: false, message: `could not download ${url}: ${err instanceof Error ? err.message : String(err)}` };
|
|
136
|
+
}
|
|
137
|
+
if (!res.ok || !res.body)
|
|
138
|
+
return { ok: false, message: `download of ${url} answered ${res.status}` };
|
|
139
|
+
const tmp = `${file}.part`;
|
|
140
|
+
await writeFile(tmp, Buffer.from(await res.arrayBuffer()), { mode: 0o755 });
|
|
141
|
+
await chmod(tmp, 0o755);
|
|
142
|
+
await rename(tmp, file);
|
|
143
|
+
return { ok: true, file };
|
|
144
|
+
}
|
|
145
|
+
export async function proxyInstalled(env = process.env) {
|
|
146
|
+
return exists(proxyBinPath(env));
|
|
147
|
+
}
|
|
148
|
+
async function readPid(env) {
|
|
149
|
+
try {
|
|
150
|
+
const pid = Number.parseInt(await readFile(pidPath(env), "utf8"), 10);
|
|
151
|
+
return Number.isFinite(pid) && pid > 0 ? pid : undefined;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function alive(pid) {
|
|
158
|
+
try {
|
|
159
|
+
process.kill(pid, 0);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Start the proxy in the background: keys from proxy.env, output to proxy.log, pid file kept. */
|
|
167
|
+
export async function startProxy(log, env = process.env) {
|
|
168
|
+
const existing = await readPid(env);
|
|
169
|
+
if (existing && alive(existing))
|
|
170
|
+
return { ok: true, pid: existing };
|
|
171
|
+
if (!(await proxyInstalled(env))) {
|
|
172
|
+
log("No proxy package for this machine came with tab; fetching the release build.");
|
|
173
|
+
const fetched = await downloadProxy(log, env);
|
|
174
|
+
if (!fetched.ok)
|
|
175
|
+
return fetched;
|
|
176
|
+
}
|
|
177
|
+
const fileEnv = await readProxyEnv(env);
|
|
178
|
+
if (!fileEnv.FLOCKTAB_CONTROL_URL) {
|
|
179
|
+
return { ok: false, message: `no ${proxyEnvPath(env)}: run tab up to save your provider keys` };
|
|
180
|
+
}
|
|
181
|
+
const logFile = await open(proxyLogPath(env), "a", 0o600);
|
|
182
|
+
const child = spawn(proxyBinPath(env), [], {
|
|
183
|
+
detached: true,
|
|
184
|
+
stdio: ["ignore", logFile.fd, logFile.fd],
|
|
185
|
+
env: { ...env, ...fileEnv, HOST: fileEnv.HOST ?? "127.0.0.1", PORT: fileEnv.PORT ?? "8787" },
|
|
186
|
+
});
|
|
187
|
+
await logFile.close();
|
|
188
|
+
if (!child.pid)
|
|
189
|
+
return { ok: false, message: "the proxy did not start" };
|
|
190
|
+
child.unref();
|
|
191
|
+
await writeFile(pidPath(env), `${child.pid}\n`, { mode: 0o600 });
|
|
192
|
+
return { ok: true, pid: child.pid };
|
|
193
|
+
}
|
|
194
|
+
export async function stopProxy(env = process.env) {
|
|
195
|
+
const pid = await readPid(env);
|
|
196
|
+
await rm(pidPath(env), { force: true });
|
|
197
|
+
if (!pid || !alive(pid))
|
|
198
|
+
return false;
|
|
199
|
+
try {
|
|
200
|
+
process.kill(pid, "SIGTERM");
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** The last lines of the proxy log, for a failed start. */
|
|
208
|
+
export async function tailProxyLog(lines = 8, env = process.env) {
|
|
209
|
+
try {
|
|
210
|
+
const text = await readFile(proxyLogPath(env), "utf8");
|
|
211
|
+
return text.trimEnd().split("\n").slice(-lines);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
}
|
package/dist/selfhost.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the local proxy answering, and wait for it after a start. The proxy
|
|
3
|
+
* itself is the `flocktab-proxy` binary (see proxy-bin.ts).
|
|
4
|
+
*/
|
|
5
|
+
export async function proxyHealth(proxyUrl, timeoutMs = 3_000) {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
8
|
+
try {
|
|
9
|
+
const response = await fetch(`${proxyUrl}/health`, { signal: controller.signal });
|
|
10
|
+
if (!response.ok)
|
|
11
|
+
return { ok: false, reason: `health returned ${response.status}` };
|
|
12
|
+
const body = (await response.json().catch(() => ({})));
|
|
13
|
+
return body.ok === true ? { ok: true } : { ok: false, reason: "ledger unavailable" };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { ok: false, reason: "not running" };
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Wait for health after a start, bounded. */
|
|
23
|
+
export async function waitHealthy(proxyUrl, totalMs = 60_000) {
|
|
24
|
+
const deadline = Date.now() + totalMs;
|
|
25
|
+
let last = { ok: false, reason: "not running" };
|
|
26
|
+
while (Date.now() < deadline) {
|
|
27
|
+
last = await proxyHealth(proxyUrl);
|
|
28
|
+
if (last.ok)
|
|
29
|
+
return last;
|
|
30
|
+
await new Promise((r) => setTimeout(r, 1_500));
|
|
31
|
+
}
|
|
32
|
+
return last;
|
|
33
|
+
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal styling for `tab`: a few colours, a box, aligned rows. No
|
|
3
|
+
* dependency. Colour follows the usual rules: NO_COLOR wins, FORCE_COLOR
|
|
4
|
+
* forces, otherwise only a real terminal gets escapes.
|
|
5
|
+
*/
|
|
6
|
+
const ESC = "\u001b[";
|
|
7
|
+
const CODES = {
|
|
8
|
+
reset: "0",
|
|
9
|
+
bold: "1",
|
|
10
|
+
dim: "2",
|
|
11
|
+
underline: "4",
|
|
12
|
+
red: "31",
|
|
13
|
+
green: "32",
|
|
14
|
+
yellow: "33",
|
|
15
|
+
blue: "34",
|
|
16
|
+
magenta: "35",
|
|
17
|
+
cyan: "36",
|
|
18
|
+
};
|
|
19
|
+
export function colorEnabled(env = process.env, isTTY = Boolean(process.stderr.isTTY)) {
|
|
20
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== "")
|
|
21
|
+
return false;
|
|
22
|
+
if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0")
|
|
23
|
+
return true;
|
|
24
|
+
if (env.TERM === "dumb")
|
|
25
|
+
return false;
|
|
26
|
+
return isTTY;
|
|
27
|
+
}
|
|
28
|
+
let enabled = colorEnabled();
|
|
29
|
+
/** Tests and callers with their own detection can flip it. */
|
|
30
|
+
export function setColor(on) {
|
|
31
|
+
enabled = on;
|
|
32
|
+
}
|
|
33
|
+
function wrap(code, text) {
|
|
34
|
+
if (!enabled || text === "")
|
|
35
|
+
return text;
|
|
36
|
+
return `${ESC}${CODES[code]}m${text}${ESC}${CODES.reset}m`;
|
|
37
|
+
}
|
|
38
|
+
export const bold = (t) => wrap("bold", t);
|
|
39
|
+
export const dim = (t) => wrap("dim", t);
|
|
40
|
+
export const underline = (t) => wrap("underline", t);
|
|
41
|
+
export const red = (t) => wrap("red", t);
|
|
42
|
+
export const green = (t) => wrap("green", t);
|
|
43
|
+
export const yellow = (t) => wrap("yellow", t);
|
|
44
|
+
export const cyan = (t) => wrap("cyan", t);
|
|
45
|
+
export const magenta = (t) => wrap("magenta", t);
|
|
46
|
+
/** Visible width: escapes do not take columns. */
|
|
47
|
+
export function stripAnsi(text) {
|
|
48
|
+
// eslint-disable-next-line no-control-regex
|
|
49
|
+
return text.replace(/\u001b\[[0-9;]*m/g, "");
|
|
50
|
+
}
|
|
51
|
+
export function width(text) {
|
|
52
|
+
return [...stripAnsi(text)].length;
|
|
53
|
+
}
|
|
54
|
+
const GLYPH = {
|
|
55
|
+
ok: "✓",
|
|
56
|
+
warn: "!",
|
|
57
|
+
fail: "✗",
|
|
58
|
+
info: "›",
|
|
59
|
+
plain: " ",
|
|
60
|
+
};
|
|
61
|
+
const TONE_PAINT = {
|
|
62
|
+
ok: green,
|
|
63
|
+
warn: yellow,
|
|
64
|
+
fail: red,
|
|
65
|
+
info: cyan,
|
|
66
|
+
plain: (t) => t,
|
|
67
|
+
};
|
|
68
|
+
/** "✓ Approved as Codex", glyph coloured, text plain. */
|
|
69
|
+
export function line(tone, text) {
|
|
70
|
+
return `${TONE_PAINT[tone](GLYPH[tone])} ${text}`;
|
|
71
|
+
}
|
|
72
|
+
/** A framed block, the way release notes and confirmations read at a glance. */
|
|
73
|
+
export function box(lines, options = {}) {
|
|
74
|
+
const pad = options.pad ?? 2;
|
|
75
|
+
const paint = TONE_PAINT[options.tone ?? "info"];
|
|
76
|
+
const title = options.title ? ` ${bold(options.title)} ` : "";
|
|
77
|
+
const inner = Math.max(width(title), ...lines.map(width)) + pad * 2;
|
|
78
|
+
const top = `╭${title ? "─" + title : ""}${"─".repeat(Math.max(0, inner - width(title) - (title ? 1 : 0)))}╮`;
|
|
79
|
+
const body = lines.map((l) => {
|
|
80
|
+
const fill = " ".repeat(Math.max(0, inner - pad - width(l)));
|
|
81
|
+
return `${paint("│")}${" ".repeat(pad)}${l}${fill}${paint("│")}`;
|
|
82
|
+
});
|
|
83
|
+
const bottom = `╰${"─".repeat(inner)}╯`;
|
|
84
|
+
return [paint(top), ...body, paint(bottom)].join("\n");
|
|
85
|
+
}
|
|
86
|
+
/** Aligned label/value rows: plain labels are dimmed, painted ones kept; padding counts visible width. */
|
|
87
|
+
export function rows(pairs, indent = 2) {
|
|
88
|
+
const w = Math.max(0, ...pairs.map(([k]) => width(k)));
|
|
89
|
+
return pairs
|
|
90
|
+
.map(([k, v]) => {
|
|
91
|
+
const pad = " ".repeat(w - width(k));
|
|
92
|
+
const label = k.includes(ESC) ? `${k}${pad}` : dim(`${k}${pad}`);
|
|
93
|
+
return `${" ".repeat(indent)}${label} ${v}`;
|
|
94
|
+
})
|
|
95
|
+
.join("\n");
|
|
96
|
+
}
|
|
97
|
+
export function heading(text) {
|
|
98
|
+
return bold(text);
|
|
99
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hanamorilabs/tab",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run any AI agent on a FlockTab tab: tab claude, tab codex, tab <command>.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"tab": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
19
|
+
"dev": "tsx src/cli.ts",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"prepublishOnly": "pnpm build"
|
|
23
|
+
},
|
|
24
|
+
"optionalDependencies": {
|
|
25
|
+
"@hanamorilabs/flocktab-proxy-darwin-arm64": "0.1.1",
|
|
26
|
+
"@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.1",
|
|
27
|
+
"@hanamorilabs/flocktab-proxy-linux-x64": "0.1.1",
|
|
28
|
+
"@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.1",
|
|
29
|
+
"@hanamorilabs/flocktab-proxy-win-x64": "0.1.1"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.18.6",
|
|
33
|
+
"tsx": "^4.20.5",
|
|
34
|
+
"typescript": "^5.9.2",
|
|
35
|
+
"vitest": "^3.2.4"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/joseairosa/flocktab.git",
|
|
43
|
+
"directory": "apps/tab"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://flocktab.com",
|
|
46
|
+
"keywords": [
|
|
47
|
+
"flocktab",
|
|
48
|
+
"claude",
|
|
49
|
+
"codex",
|
|
50
|
+
"agent",
|
|
51
|
+
"budget",
|
|
52
|
+
"proxy"
|
|
53
|
+
]
|
|
54
|
+
}
|